diff --git a/README.md b/README.md index fd8ac36..76e91aa 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite. - Accurate countdown after background-tab throttling - Sound notification when a segment ends +## Stage 2 + +- Custom focus, short-break, and long-break durations +- Six built-in backgrounds: three gradients and three local illustrated scenes +- Adjustable background overlay and text color +- Four clock and timer font styles +- Live customization preview with one-click reset +- Automatic settings persistence in IndexedDB + +All preferences stay in the browser. No account or backend is required. + ## Development ```bash @@ -27,6 +38,14 @@ npm run lint npm run build ``` +## Architecture + +- React and TypeScript for the interface and timer state +- Absolute timestamps for accurate countdowns after tab throttling +- Native IndexedDB for persistent browser settings +- CSS custom properties for live themes, fonts, colors, and overlays +- Vitest and Testing Library for model, persistence, and interface tests + ## License [MIT](LICENSE) diff --git a/package-lock.json b/package-lock.json index 10e074f..52e49b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", + "fake-indexeddb": "^6.2.5", "globals": "^17.7.0", "jsdom": "^29.1.1", "typescript": "^6.0.3", @@ -2236,6 +2237,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 2132b82..db6e9d8 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint": "^10.7.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", + "fake-indexeddb": "^6.2.5", "globals": "^17.7.0", "jsdom": "^29.1.1", "typescript": "^6.0.3", diff --git a/public/backgrounds/dusk.svg b/public/backgrounds/dusk.svg new file mode 100644 index 0000000..c250116 --- /dev/null +++ b/public/backgrounds/dusk.svg @@ -0,0 +1,27 @@ + + Mountain dusk + Layered mountains beneath a warm dusk sky + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/backgrounds/forest.svg b/public/backgrounds/forest.svg new file mode 100644 index 0000000..db6c15d --- /dev/null +++ b/public/backgrounds/forest.svg @@ -0,0 +1,36 @@ + + Forest stillness + A quiet layered evergreen forest in soft mist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/backgrounds/ocean.svg b/public/backgrounds/ocean.svg new file mode 100644 index 0000000..cb59bd6 --- /dev/null +++ b/public/backgrounds/ocean.svg @@ -0,0 +1,33 @@ + + Open ocean + A calm blue ocean beneath a glowing horizon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.css b/src/App.css index f7bd3a1..eedd06a 100644 --- a/src/App.css +++ b/src/App.css @@ -8,29 +8,44 @@ min-height: 100vh; overflow: hidden; padding: 34px clamp(24px, 5vw, 76px) 28px; - background: - radial-gradient(circle at 50% 45%, rgba(76, 68, 135, 0.25), transparent 32%), - linear-gradient(135deg, #15142a 0%, #242044 48%, #16152b 100%); + background: #15142a; + font-family: var(--app-font, 'Space Grotesk', sans-serif); isolation: isolate; - transition: background 400ms ease; + transition: color 250ms ease; +} + +.app::before, +.app::after { + position: absolute; + inset: 0; + z-index: -3; + content: ''; + pointer-events: none; +} + +.app::before { + background-image: var(--background-image); + background-position: center; + background-size: cover; + transition: background-image 350ms ease; +} + +.app::after { + z-index: -2; + background: rgb(7 8 20 / var(--overlay-opacity, 0.18)); + transition: background 250ms ease; } .app.phase-shortBreak { --accent: #42d6ad; --accent-strong: #20ad87; --glow: rgba(66, 214, 173, 0.32); - background: - radial-gradient(circle at 50% 45%, rgba(41, 151, 129, 0.22), transparent 32%), - linear-gradient(135deg, #101f28 0%, #183c42 48%, #10232c 100%); } .app.phase-longBreak { --accent: #ff9d66; --accent-strong: #ef754c; --glow: rgba(255, 157, 102, 0.32); - background: - radial-gradient(circle at 50% 45%, rgba(192, 88, 73, 0.2), transparent 32%), - linear-gradient(135deg, #2b1720 0%, #4e2834 48%, #271722 100%); } .orb { @@ -63,12 +78,43 @@ justify-content: space-between; } +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.customize-button, +.icon-button, +.reset-settings { + color: inherit; + font: inherit; + cursor: pointer; +} + +.customize-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 999px; + background: rgba(13, 12, 30, 0.25); + backdrop-filter: blur(14px); + transition: border-color 160ms ease, background 160ms ease; +} + +.customize-button:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); +} + .brand { display: inline-flex; align-items: center; gap: 12px; - color: #fff; - font-family: 'Space Grotesk', sans-serif; + color: inherit; + font-family: var(--app-font, 'Space Grotesk', sans-serif); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.02em; @@ -90,9 +136,9 @@ padding: 9px 14px; border: 1px solid rgba(255, 255, 255, 0.13); border-radius: 999px; - color: rgba(255, 255, 255, 0.76); + color: inherit; background: rgba(13, 12, 30, 0.25); - font-family: 'Space Grotesk', sans-serif; + font-family: var(--app-font, 'Space Grotesk', sans-serif); font-variant-numeric: tabular-nums; backdrop-filter: blur(14px); } @@ -122,7 +168,7 @@ h1 { margin: 0; - font-family: 'Space Grotesk', sans-serif; + font-family: var(--app-font, 'Space Grotesk', sans-serif); font-size: clamp(2rem, 5vw, 3.1rem); letter-spacing: -0.055em; } @@ -192,7 +238,7 @@ h1 { .countdown { display: block; margin: 6px 0 18px; - font-family: 'Space Grotesk', sans-serif; + font-family: var(--app-font, 'Space Grotesk', sans-serif); font-size: clamp(5rem, 15vw, 8.8rem); font-weight: 600; line-height: 1; @@ -254,7 +300,7 @@ h1 { animation: pulse 1.6s ease-in-out infinite; } -footer { +.app > footer { display: flex; justify-content: center; gap: 10px; @@ -262,15 +308,287 @@ footer { font-size: 0.78rem; } +.settings-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + justify-content: flex-end; + background: rgba(4, 5, 15, 0.54); + backdrop-filter: blur(10px); + animation: fade-in 180ms ease-out; +} + +.settings-panel { + display: grid; + grid-template-rows: auto 1fr auto; + width: min(100%, 480px); + height: 100%; + color: #f8f7ff; + background: linear-gradient(160deg, rgba(34, 32, 58, 0.98), rgba(17, 17, 33, 0.99)); + border-left: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: -30px 0 90px rgba(2, 3, 12, 0.4); + animation: slide-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.settings-header, +.settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 24px 28px; +} + +.settings-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.09); +} + +.settings-kicker { + margin: 0 0 3px; + color: #a990ff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.settings-header h2 { + margin: 0; + font-size: 1.65rem; + letter-spacing: -0.04em; +} + +.icon-button { + display: grid; + width: 40px; + height: 40px; + padding: 0 0 3px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 50%; + background: rgba(255, 255, 255, 0.06); + font-size: 1.7rem; + line-height: 1; +} + +.settings-scroll { + overflow-y: auto; + padding: 6px 28px 28px; +} + +.settings-section { + padding: 24px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 16px; +} + +.section-heading h3 { + margin: 0; + font-size: 0.94rem; +} + +.section-heading > span, +.settings-footer > span { + color: rgba(255, 255, 255, 0.42); + font-size: 0.72rem; +} + +.duration-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.duration-grid label, +.visual-controls > label { + display: grid; + gap: 8px; + color: rgba(255, 255, 255, 0.58); + font-size: 0.72rem; +} + +.duration-grid input, +.visual-controls select { + width: 100%; + min-width: 0; + padding: 11px 12px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 12px; + outline: none; + color: #fff; + background: rgba(255, 255, 255, 0.065); + font: inherit; + font-size: 0.9rem; +} + +.duration-grid input:focus, +.visual-controls select:focus { + border-color: #9175f7; + box-shadow: 0 0 0 3px rgba(145, 117, 247, 0.14); +} + +.background-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.background-option { + display: grid; + gap: 7px; + padding: 5px 5px 9px; + border: 1px solid transparent; + border-radius: 13px; + color: rgba(255, 255, 255, 0.58); + font-size: 0.68rem; + cursor: pointer; + transition: 160ms ease; +} + +.background-option:hover, +.background-option.selected { + border-color: rgba(255, 255, 255, 0.18); + color: #fff; + background: rgba(255, 255, 255, 0.055); +} + +.background-option:focus-within { + outline: 2px solid #b6a5ff; + outline-offset: 2px; +} + +.background-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.background-swatch { + display: block; + width: 100%; + aspect-ratio: 1.55; + border-radius: 9px; + background: var(--preview); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.background-option.selected .background-swatch { + box-shadow: inset 0 0 0 2px #b6a5ff, 0 5px 18px rgba(0, 0, 0, 0.22); +} + +.visual-controls { + display: grid; + gap: 20px; + border-bottom: 0; +} + +.visual-controls label > span { + display: flex; + justify-content: space-between; +} + +.visual-controls strong { + color: rgba(255, 255, 255, 0.85); + font-weight: 500; +} + +.visual-controls input[type='range'] { + width: 100%; + accent-color: #8d6bff; +} + +.color-control { + grid-template-columns: 1fr auto; + align-items: center; +} + +.color-input-wrap { + display: flex !important; + align-items: center; + gap: 9px; +} + +.color-input-wrap input { + width: 34px; + height: 28px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + cursor: pointer; +} + +.color-input-wrap code { + color: rgba(255, 255, 255, 0.62); + font-size: 0.74rem; +} + +.settings-footer { + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.reset-settings { + padding: 9px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: transparent; + font-size: 0.75rem; +} + +.reset-settings:hover { + background: rgba(255, 255, 255, 0.07); +} + @keyframes pulse { 50% { opacity: 0.35; } } +@keyframes fade-in { + from { opacity: 0; } +} + +@keyframes slide-in { + from { transform: translateX(24px); opacity: 0; } +} + @media (max-width: 620px) { .app { padding: 22px 16px 20px; } + .customize-button { + width: 40px; + height: 40px; + justify-content: center; + padding: 0; + font-size: 0; + } + + .customize-button span { + font-size: 1rem; + } + + .settings-header, + .settings-footer { + padding-inline: 20px; + } + + .settings-scroll { + padding-inline: 20px; + } + + .duration-grid { + grid-template-columns: 1fr; + } + .timer-card { padding: 28px 18px; border-radius: 28px; @@ -295,7 +613,7 @@ footer { flex: 1; } - footer { + .app > footer { flex-wrap: wrap; } } diff --git a/src/App.test.tsx b/src/App.test.tsx index e6fbd76..81ec66d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,14 +1,53 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' +import type { AppSettings } from './features/settings/settings' +import { DEFAULT_SETTINGS } from './features/settings/settings' +import type { SettingsStore } from './features/settings/settings-store' import type { CompletionSound } from './features/timer/sound' +import appCss from './App.css?raw' -function renderApp() { +class MemorySettingsStore implements SettingsStore { + constructor(public settings: AppSettings | null = null) {} + + async load() { + return this.settings + } + + async save(settings: AppSettings) { + this.settings = settings + } +} + +const pendingStore: SettingsStore = { + load: () => new Promise(() => undefined), + save: async () => undefined, +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +function renderApp(settingsStore: SettingsStore = pendingStore) { const sound: CompletionSound = { prepare: vi.fn(), play: vi.fn(), } - render() + render() + return sound +} + +async function renderHydratedApp(settingsStore: SettingsStore = new MemorySettingsStore()) { + const sound = renderApp(settingsStore) + await act(async () => { + await Promise.resolve() + }) return sound } @@ -31,8 +70,8 @@ describe('Pomodoro app', () => { expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument() }) - it('starts, pauses, resumes, and resets the countdown', () => { - const sound = renderApp() + it('starts, pauses, resumes, and resets the countdown', async () => { + const sound = await renderHydratedApp() fireEvent.click(screen.getByRole('button', { name: 'Start' })) expect(sound.prepare).toHaveBeenCalledOnce() @@ -54,8 +93,8 @@ describe('Pomodoro app', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() }) - it('announces completion and switches to a short break', () => { - const sound = renderApp() + it('announces completion and switches to a short break', async () => { + const sound = await renderHydratedApp() fireEvent.click(screen.getByRole('button', { name: 'Start' })) act(() => vi.advanceTimersByTime(25 * 60_000)) @@ -66,8 +105,8 @@ describe('Pomodoro app', () => { expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument() }) - it('lets the user select focus and break segments', () => { - renderApp() + it('lets the user select focus and break segments', async () => { + await renderHydratedApp() fireEvent.click(screen.getByRole('button', { name: 'Long break' })) expect(screen.getByLabelText('Time remaining')).toHaveTextContent('15:00') @@ -78,4 +117,169 @@ describe('Pomodoro app', () => { fireEvent.click(screen.getByRole('button', { name: 'Focus' })) expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00') }) + + it('customizes timer durations from the settings panel', async () => { + await renderHydratedApp() + + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } }) + fireEvent.change(screen.getByLabelText('Short break minutes'), { target: { value: '8' } }) + fireEvent.change(screen.getByLabelText('Long break minutes'), { target: { value: '25' } }) + + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00') + expect(screen.getByText('40 min focus')).toBeInTheDocument() + expect(screen.getByText('8 min short break')).toBeInTheDocument() + expect(screen.getByText('25 min long break')).toBeInTheDocument() + }) + + it('applies background, overlay, font, and text color customization', async () => { + await renderHydratedApp() + + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' })) + fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } }) + fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } }) + fireEvent.change(screen.getByLabelText('Text color'), { target: { value: '#fff2d6' } }) + + const app = screen.getByTestId('app') + expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg') + expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4') + expect(app.style.getPropertyValue('--app-font')).toContain('Georgia') + expect(app).toHaveStyle({ color: '#fff2d6' }) + }) + + it('restores and persists customization through the settings store', async () => { + vi.useRealTimers() + const store = new MemorySettingsStore({ + ...DEFAULT_SETTINGS, + focusMinutes: 45, + backgroundId: 'ocean', + textColor: '#dff8ff', + }) + + await renderHydratedApp(store) + + await waitFor(() => { + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('45:00') + }) + expect(screen.getByTestId('app').style.getPropertyValue('--background-image')).toContain('ocean.svg') + + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '50' } }) + + await waitFor(() => expect(store.settings?.focusMinutes).toBe(50)) + }) + + it('resets customization to defaults', async () => { + await renderHydratedApp() + + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } }) + fireEvent.click(screen.getByRole('button', { name: 'Reset customization' })) + + expect(screen.getByLabelText('Focus minutes')).toHaveValue(25) + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00') + }) + + it('gates timer and customization controls until settings hydration completes', async () => { + const load = deferred() + const store: SettingsStore = { + load: () => load.promise, + save: vi.fn(async () => undefined), + } + renderApp(store) + + expect(screen.getByRole('button', { name: 'Customize' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Long break' })).toBeDisabled() + + await act(async () => { + load.resolve({ ...DEFAULT_SETTINGS, focusMinutes: 40 }) + await load.promise + }) + + expect(screen.getByRole('button', { name: 'Customize' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled() + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00') + }) + + it('does not persist defaults after load failure and reports that changes will not be saved', async () => { + const save = vi.fn(async () => undefined) + const store: SettingsStore = { + load: async () => { throw new Error('IndexedDB unavailable') }, + save, + } + + await renderHydratedApp(store) + + expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + expect(screen.getByText(/settings unavailable/i)).toHaveAttribute('role', 'status') + expect(screen.queryByText('Changes save automatically')).not.toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } }) + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00') + expect(save).not.toHaveBeenCalled() + }) + + it('keeps settings saves serialized so the latest change wins', async () => { + const pendingUserSaves: Array>> = [] + const savedFocusMinutes: number[] = [] + const store: SettingsStore = { + load: async () => null, + save: (settings) => { + if (settings.focusMinutes === DEFAULT_SETTINGS.focusMinutes) { + return Promise.resolve() + } + savedFocusMinutes.push(settings.focusMinutes) + const pending = deferred() + pendingUserSaves.push(pending) + return pending.promise + }, + } + await renderHydratedApp(store) + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } }) + fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '41' } }) + + expect(savedFocusMinutes).toEqual([40]) + + await act(async () => { + pendingUserSaves[0].resolve() + await pendingUserSaves[0].promise + }) + expect(savedFocusMinutes).toEqual([40, 41]) + + await act(async () => { + pendingUserSaves[1].resolve() + await pendingUserSaves[1].promise + }) + }) + + it('contains keyboard focus in the modal, closes on Escape, and restores the trigger', async () => { + await renderHydratedApp() + const customize = screen.getByRole('button', { name: 'Customize' }) + fireEvent.click(customize) + + const close = screen.getByRole('button', { name: 'Close customization' }) + const reset = screen.getByRole('button', { name: 'Reset customization' }) + expect(close).toHaveFocus() + + reset.focus() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Tab' }) + expect(close).toHaveFocus() + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Tab', shiftKey: true }) + expect(reset).toHaveFocus() + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(customize).toHaveFocus() + }) + + it('shows a keyboard focus indicator around visually hidden background radios', () => { + expect(appCss).toMatch(/\.background-option:focus-within\s*\{/) + }) }) diff --git a/src/App.tsx b/src/App.tsx index 11ddb21..c468deb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import './App.css' +import SettingsPanel from './features/settings/SettingsPanel' +import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './features/settings/options' +import { + DEFAULT_SETTINGS, + normalizeSettings, + settingsToDurations, + type AppSettings, +} from './features/settings/settings' +import { + indexedDbSettingsStore, + type SettingsStore, +} from './features/settings/settings-store' import { createTimer, pauseTimer, @@ -7,6 +19,7 @@ import { selectPhase, startTimer, tickTimer, + updateDurations, type TimerPhase, } from './features/timer/timer' import { @@ -27,6 +40,7 @@ const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [ ] interface AppProps { + settingsStore?: SettingsStore sound?: CompletionSound } @@ -45,10 +59,78 @@ function formatClock(date: Date): string { }).format(date) } -function App({ sound = completionSound }: AppProps) { +function App({ + settingsStore = indexedDbSettingsStore, + sound = completionSound, +}: AppProps) { const [timer, setTimer] = useState(createTimer) const [currentTime, setCurrentTime] = useState(() => new Date()) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading') + const [settingsOpen, setSettingsOpen] = useState(false) const completionPending = useRef(false) + const customizeButtonRef = useRef(null) + const saveInFlight = useRef(false) + const pendingSettingsSave = useRef(null) + + const updateSettings = useCallback((next: AppSettings) => { + const normalized = normalizeSettings(next) + const durations = settingsToDurations(normalized) + setSettings(normalized) + setTimer((current) => { + const unchanged = Object.entries(durations).every( + ([phase, duration]) => current.durations[phase as TimerPhase] === duration, + ) + return unchanged ? current : updateDurations(current, durations) + }) + return normalized + }, []) + + const saveLatestSettings = useCallback((next: AppSettings) => { + pendingSettingsSave.current = next + if (saveInFlight.current) return + + saveInFlight.current = true + void (async () => { + try { + while (pendingSettingsSave.current) { + const settingsToSave = pendingSettingsSave.current + pendingSettingsSave.current = null + await settingsStore.save(settingsToSave) + } + } catch { + pendingSettingsSave.current = null + setPersistenceState('error') + } finally { + saveInFlight.current = false + } + })() + }, [settingsStore]) + + const applySettings = useCallback((next: AppSettings) => { + const normalized = updateSettings(next) + if (persistenceState === 'ready') { + saveLatestSettings(normalized) + } + }, [persistenceState, saveLatestSettings, updateSettings]) + + useEffect(() => { + let active = true + settingsStore.load() + .then((stored) => { + if (!active) return + if (stored) updateSettings(stored) + setPersistenceState('ready') + }) + .catch(() => { + if (active) setPersistenceState('error') + }) + + return () => { + active = false + } + }, [settingsStore, updateSettings]) + const updateTimer = useCallback(() => { setTimer((current) => { @@ -99,14 +181,32 @@ function App({ sound = completionSound }: AppProps) { setTimer((current) => startTimer(current, Date.now())) } + const closeSettings = () => { + setSettingsOpen(false) + customizeButtonRef.current?.focus() + } + + const settingsHydrated = persistenceState !== 'loading' + const cycleProgress = timer.completedFocusSessions % 4 const visibleProgress = timer.phase === 'longBreak' && cycleProgress === 0 && timer.completedFocusSessions > 0 ? 4 : cycleProgress + const selectedBackground = BACKGROUND_OPTIONS.find( + (background) => background.id === settings.backgroundId, + ) ?? BACKGROUND_OPTIONS[0] + const selectedFont = FONT_OPTIONS.find((font) => font.id === settings.fontId) ?? FONT_OPTIONS[0] + const appStyle = { + '--app-font': selectedFont.css, + '--background-image': selectedBackground.css, + '--overlay-opacity': String(settings.overlayOpacity), + color: settings.textColor, + } as CSSProperties + return ( -
+
) } diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx new file mode 100644 index 0000000..d6613e2 --- /dev/null +++ b/src/features/settings/SettingsPanel.tsx @@ -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(null) + const closeButtonRef = useRef(null) + + useEffect(() => { + closeButtonRef.current?.focus() + }, []) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose() + return + } + + if (event.key !== 'Tab') return + + const focusable = Array.from( + panelRef.current?.querySelectorAll(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 = (key: K, value: AppSettings[K]) => { + onChange({ ...settings, [key]: value }) + } + + return ( +
+ +
+ ) +} + +export default SettingsPanel diff --git a/src/features/settings/options.ts b/src/features/settings/options.ts new file mode 100644 index 0000000..82049b8 --- /dev/null +++ b/src/features/settings/options.ts @@ -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" }, +] diff --git a/src/features/settings/settings-store.ts b/src/features/settings/settings-store.ts new file mode 100644 index 0000000..aaa0a1c --- /dev/null +++ b/src/features/settings/settings-store.ts @@ -0,0 +1,68 @@ +import { normalizeSettings, type AppSettings } from './settings' + +export interface SettingsStore { + load: () => Promise + save: (settings: AppSettings) => Promise +} + +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 { + 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 { + 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 { + 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(request: IDBRequest): Promise { + 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 { + 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() diff --git a/src/features/settings/settings.test.ts b/src/features/settings/settings.test.ts new file mode 100644 index 0000000..17b3e9e --- /dev/null +++ b/src/features/settings/settings.test.ts @@ -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: '