[verified] feat: add timer customization and themes

This commit is contained in:
2026-07-22 23:45:59 +03:00
parent 128bdc5e16
commit 723b4a221d
16 changed files with 1358 additions and 44 deletions
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from 'react'
import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options'
import type { AppSettings } from './settings'
interface SettingsPanelProps {
settings: AppSettings
persistenceError: boolean
onChange: (settings: AppSettings) => void
onClose: () => void
onReset: () => void
}
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) {
const panelRef = useRef<HTMLElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
closeButtonRef.current?.focus()
}, [])
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab') return
const focusable = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [],
)
const first = focusable[0]
const last = focusable.at(-1)
if (!first || !last) return
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
const update = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
onChange({ ...settings, [key]: value })
}
return (
<div className="settings-backdrop" role="presentation">
<aside
aria-labelledby="settings-title"
aria-modal="true"
className="settings-panel"
onKeyDown={handleKeyDown}
ref={panelRef}
role="dialog"
>
<header className="settings-header">
<div>
<p className="settings-kicker">Make it yours</p>
<h2 id="settings-title">Customize</h2>
</div>
<button aria-label="Close customization" className="icon-button" onClick={onClose} ref={closeButtonRef} type="button">
<span aria-hidden="true">×</span>
</button>
</header>
<div className="settings-scroll">
<section className="settings-section">
<div className="section-heading">
<h3>Timer</h3>
<span>minutes</span>
</div>
<div className="duration-grid">
<label>
<span>Focus minutes</span>
<input
max="120"
min="1"
onChange={(event) => update('focusMinutes', Number(event.target.value))}
type="number"
value={settings.focusMinutes}
/>
</label>
<label>
<span>Short break minutes</span>
<input
max="60"
min="1"
onChange={(event) => update('shortBreakMinutes', Number(event.target.value))}
type="number"
value={settings.shortBreakMinutes}
/>
</label>
<label>
<span>Long break minutes</span>
<input
max="90"
min="1"
onChange={(event) => update('longBreakMinutes', Number(event.target.value))}
type="number"
value={settings.longBreakMinutes}
/>
</label>
</div>
</section>
<section className="settings-section">
<div className="section-heading">
<h3>Background</h3>
<span>live preview</span>
</div>
<div className="background-grid">
{BACKGROUND_OPTIONS.map((background) => (
<label
className={`background-option ${settings.backgroundId === background.id ? 'selected' : ''}`}
key={background.id}
style={{ '--preview': background.preview } as CSSProperties}
>
<input
checked={settings.backgroundId === background.id}
name="background"
onChange={() => update('backgroundId', background.id)}
type="radio"
/>
<span className="background-swatch" aria-hidden="true" />
<span>{background.label}</span>
</label>
))}
</div>
</section>
<section className="settings-section visual-controls">
<label>
<span>Clock font</span>
<select onChange={(event) => update('fontId', event.target.value as AppSettings['fontId'])} value={settings.fontId}>
{FONT_OPTIONS.map((font) => <option key={font.id} value={font.id}>{font.label}</option>)}
</select>
</label>
<label>
<span>Overlay darkness <strong aria-hidden="true">{Math.round(settings.overlayOpacity * 100)}%</strong></span>
<input
aria-label="Overlay darkness"
max="0.8"
min="0"
onChange={(event) => update('overlayOpacity', Number(event.target.value))}
step="0.05"
type="range"
value={settings.overlayOpacity}
/>
</label>
<label className="color-control">
<span>Text color</span>
<span className="color-input-wrap">
<input
aria-label="Text color"
onChange={(event) => update('textColor', event.target.value)}
type="color"
value={settings.textColor}
/>
<code>{settings.textColor.toUpperCase()}</code>
</span>
</label>
</section>
</div>
<footer className="settings-footer">
<button className="reset-settings" onClick={onReset} type="button">Reset customization</button>
<span role="status">
{persistenceError
? 'Settings unavailable — changes wont be saved'
: 'Changes save automatically'}
</span>
</footer>
</aside>
</div>
)
}
export default SettingsPanel
+52
View File
@@ -0,0 +1,52 @@
import type { BackgroundId, FontId } from './settings'
export const BACKGROUND_OPTIONS: Array<{
id: BackgroundId
label: string
css: string
preview: string
}> = [
{
id: 'violet',
label: 'Violet haze',
css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)',
preview: 'linear-gradient(135deg, #19172f, #684167)',
},
{
id: 'sunset',
label: 'Soft sunset',
css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)',
preview: 'linear-gradient(135deg, #33294e, #ef8a70)',
},
{
id: 'aurora',
label: 'Quiet aurora',
css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d',
preview: 'linear-gradient(135deg, #183244, #2e9f83)',
},
{
id: 'dusk',
label: 'Mountain dusk',
css: 'url("/backgrounds/dusk.svg")',
preview: 'linear-gradient(135deg, #37416f, #d68a79)',
},
{
id: 'forest',
label: 'Forest stillness',
css: 'url("/backgrounds/forest.svg")',
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
},
{
id: 'ocean',
label: 'Open ocean',
css: 'url("/backgrounds/ocean.svg")',
preview: 'linear-gradient(135deg, #173f5f, #53b6c3)',
},
]
export const FONT_OPTIONS: Array<{ id: FontId; label: string; css: string }> = [
{ id: 'space', label: 'Space Grotesk', css: "'Space Grotesk', sans-serif" },
{ id: 'sans', label: 'Clean sans', css: "'DM Sans', sans-serif" },
{ id: 'serif', label: 'Editorial serif', css: "Georgia, 'Times New Roman', serif" },
{ id: 'mono', label: 'Focus mono', css: "ui-monospace, 'SFMono-Regular', Consolas, monospace" },
]
+68
View File
@@ -0,0 +1,68 @@
import { normalizeSettings, type AppSettings } from './settings'
export interface SettingsStore {
load: () => Promise<AppSettings | null>
save: (settings: AppSettings) => Promise<void>
}
const STORE_NAME = 'preferences'
const SETTINGS_KEY = 'current'
export class IndexedDbSettingsStore implements SettingsStore {
constructor(
private readonly factory: IDBFactory = globalThis.indexedDB,
private readonly databaseName = 'pomodoro',
) {}
async load(): Promise<AppSettings | null> {
const database = await this.open()
try {
const transaction = database.transaction(STORE_NAME, 'readonly')
const value = await requestResult(transaction.objectStore(STORE_NAME).get(SETTINGS_KEY))
return value === undefined ? null : normalizeSettings(value)
} finally {
database.close()
}
}
async save(settings: AppSettings): Promise<void> {
const database = await this.open()
try {
const transaction = database.transaction(STORE_NAME, 'readwrite')
transaction.objectStore(STORE_NAME).put(settings, SETTINGS_KEY)
await transactionComplete(transaction)
} finally {
database.close()
}
}
private open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = this.factory.open(this.databaseName, 1)
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Unable to open settings database'))
})
}
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
function transactionComplete(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
}
export const indexedDbSettingsStore = new IndexedDbSettingsStore()
+70
View File
@@ -0,0 +1,70 @@
import { IDBFactory } from 'fake-indexeddb'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_SETTINGS,
normalizeSettings,
settingsToDurations,
type AppSettings,
} from './settings'
import { IndexedDbSettingsStore } from './settings-store'
describe('application settings', () => {
it('uses an overlay default aligned with the slider step', () => {
expect(DEFAULT_SETTINGS.overlayOpacity).toBe(0.2)
})
it('converts duration settings from minutes to milliseconds', () => {
expect(settingsToDurations({
...DEFAULT_SETTINGS,
focusMinutes: 40,
shortBreakMinutes: 8,
longBreakMinutes: 25,
})).toEqual({
focus: 40 * 60_000,
shortBreak: 8 * 60_000,
longBreak: 25 * 60_000,
})
})
it('normalizes persisted values to safe supported settings', () => {
expect(normalizeSettings({
focusMinutes: 999,
shortBreakMinutes: 0,
longBreakMinutes: 20.8,
fontId: 'unknown',
backgroundId: 'missing',
overlayOpacity: 4,
textColor: '<script>',
})).toEqual({
...DEFAULT_SETTINGS,
focusMinutes: 120,
shortBreakMinutes: 1,
longBreakMinutes: 21,
overlayOpacity: 0.8,
})
})
})
describe('IndexedDbSettingsStore', () => {
it('returns null before any settings have been saved', async () => {
const store = new IndexedDbSettingsStore(new IDBFactory(), 'empty-settings')
await expect(store.load()).resolves.toBeNull()
})
it('persists and restores settings', async () => {
const store = new IndexedDbSettingsStore(new IDBFactory(), 'saved-settings')
const settings: AppSettings = {
...DEFAULT_SETTINGS,
focusMinutes: 45,
fontId: 'serif',
backgroundId: 'forest',
overlayOpacity: 0.35,
textColor: '#fff2d6',
}
await store.save(settings)
await expect(store.load()).resolves.toEqual(settings)
})
})
+77
View File
@@ -0,0 +1,77 @@
import type { TimerDurations } from '../timer/timer'
export const FONT_IDS = ['space', 'sans', 'serif', 'mono'] as const
export type FontId = (typeof FONT_IDS)[number]
export const BACKGROUND_IDS = [
'violet',
'sunset',
'aurora',
'dusk',
'forest',
'ocean',
] as const
export type BackgroundId = (typeof BACKGROUND_IDS)[number]
export interface AppSettings {
focusMinutes: number
shortBreakMinutes: number
longBreakMinutes: number
fontId: FontId
backgroundId: BackgroundId
overlayOpacity: number
textColor: string
}
export const DEFAULT_SETTINGS: AppSettings = {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
fontId: 'space',
backgroundId: 'violet',
overlayOpacity: 0.2,
textColor: '#f8f7ff',
}
function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback
}
return Math.min(maximum, Math.max(minimum, Math.round(value)))
}
function isOneOf<T extends readonly string[]>(value: unknown, values: T): value is T[number] {
return typeof value === 'string' && values.includes(value)
}
export function normalizeSettings(value: unknown): AppSettings {
const candidate = value && typeof value === 'object'
? value as Partial<Record<keyof AppSettings, unknown>>
: {}
return {
focusMinutes: clampInteger(candidate.focusMinutes, 1, 120, DEFAULT_SETTINGS.focusMinutes),
shortBreakMinutes: clampInteger(candidate.shortBreakMinutes, 1, 60, DEFAULT_SETTINGS.shortBreakMinutes),
longBreakMinutes: clampInteger(candidate.longBreakMinutes, 1, 90, DEFAULT_SETTINGS.longBreakMinutes),
fontId: isOneOf(candidate.fontId, FONT_IDS) ? candidate.fontId : DEFAULT_SETTINGS.fontId,
backgroundId: isOneOf(candidate.backgroundId, BACKGROUND_IDS)
? candidate.backgroundId
: DEFAULT_SETTINGS.backgroundId,
overlayOpacity:
typeof candidate.overlayOpacity === 'number' && Number.isFinite(candidate.overlayOpacity)
? Math.min(0.8, Math.max(0, candidate.overlayOpacity))
: DEFAULT_SETTINGS.overlayOpacity,
textColor:
typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor)
? candidate.textColor
: DEFAULT_SETTINGS.textColor,
}
}
export function settingsToDurations(settings: AppSettings): TimerDurations {
return {
focus: settings.focusMinutes * 60_000,
shortBreak: settings.shortBreakMinutes * 60_000,
longBreak: settings.longBreakMinutes * 60_000,
}
}