[verified] feat: add timer customization and themes
This commit is contained in:
@@ -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 won’t be saved'
|
||||
: 'Changes save automatically'}
|
||||
</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPanel
|
||||
@@ -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" },
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user