[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
+19
View File
@@ -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)
+11
View File
@@ -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",
+1
View File
@@ -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",
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
<title id="title">Mountain dusk</title>
<desc id="description">Layered mountains beneath a warm dusk sky</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#27335d"/>
<stop offset="0.52" stop-color="#8c5e78"/>
<stop offset="1" stop-color="#e9a67e"/>
</linearGradient>
<radialGradient id="sun" cx="50%" cy="50%" r="50%">
<stop offset="0" stop-color="#ffe6b5" stop-opacity=".92"/>
<stop offset="1" stop-color="#ffbd8a" stop-opacity="0"/>
</radialGradient>
<linearGradient id="front" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#202644"/>
<stop offset="1" stop-color="#12172e"/>
</linearGradient>
<filter id="blur"><feGaussianBlur stdDeviation="28"/></filter>
</defs>
<rect width="1600" height="900" fill="url(#sky)"/>
<circle cx="1150" cy="275" r="210" fill="url(#sun)" filter="url(#blur)"/>
<circle cx="1150" cy="275" r="62" fill="#ffe0b3" opacity=".86"/>
<path d="M0 610 220 438l180 122 255-266 235 240 200-178 290 254 220-118v408H0Z" fill="#48506d" opacity=".72"/>
<path d="M0 702 280 520l212 148 302-250 238 202 190-116 378 206v190H0Z" fill="#2c324e" opacity=".9"/>
<path d="M0 790 260 650l255 102 260-194 284 144 212-86 329 132v152H0Z" fill="url(#front)"/>
<path d="M0 730c240-56 410-38 634 8s409 45 966-38v200H0Z" fill="#12162a" opacity=".58"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
<title id="title">Forest stillness</title>
<desc id="description">A quiet layered evergreen forest in soft mist</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#507c78"/>
<stop offset=".55" stop-color="#8daf93"/>
<stop offset="1" stop-color="#d2c6a4"/>
</linearGradient>
<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#315b52"/>
<stop offset="1" stop-color="#102c2c"/>
</linearGradient>
<filter id="mist"><feGaussianBlur stdDeviation="22"/></filter>
</defs>
<rect width="1600" height="900" fill="url(#sky)"/>
<circle cx="1160" cy="210" r="165" fill="#f7e5b5" opacity=".32" filter="url(#mist)"/>
<g fill="#547c68" opacity=".6">
<path d="m80 590 130-300 130 300h-76l95 196H61l96-196Z"/>
<path d="m390 610 110-260 110 260h-66l84 174H372l83-174Z"/>
<path d="m1260 600 125-300 125 300h-74l90 184h-282l91-184Z"/>
<path d="m980 605 100-235 101 235h-60l75 154H966l74-154Z"/>
</g>
<path d="M0 590c340-90 634-45 920 17s489 25 680-34v327H0Z" fill="#b9c3a5" opacity=".32"/>
<g fill="#234b43" opacity=".88">
<path d="m-20 725 178-430 179 430H224l128 175H-35l128-175Z"/>
<path d="m525 725 160-388 161 388H744l113 175H514l114-175Z"/>
<path d="m1110 725 183-445 184 445h-116l130 175h-397l130-175Z"/>
</g>
<path d="M0 760c270-78 540-46 790 12 252 59 498 45 810-30v158H0Z" fill="url(#ground)"/>
<g fill="#153934">
<path d="m240 820 92-233 92 233h-58l62 80H236l62-80Z"/>
<path d="m880 830 105-269 106 269h-67l72 70H874l72-70Z"/>
<path d="m1430 835 75-190 76 190h-48l50 65h-156l51-65Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
<title id="title">Open ocean</title>
<desc id="description">A calm blue ocean beneath a glowing horizon</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#122b52"/>
<stop offset=".62" stop-color="#397f9c"/>
<stop offset="1" stop-color="#9bd1ca"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop stop-color="#2d8096"/>
<stop offset="1" stop-color="#092944"/>
</linearGradient>
<radialGradient id="glow">
<stop stop-color="#f7e6bd" stop-opacity=".74"/>
<stop offset="1" stop-color="#f7e6bd" stop-opacity="0"/>
</radialGradient>
<filter id="blur"><feGaussianBlur stdDeviation="28"/></filter>
</defs>
<rect width="1600" height="900" fill="url(#sky)"/>
<ellipse cx="480" cy="390" rx="280" ry="185" fill="url(#glow)" filter="url(#blur)"/>
<path d="M0 468c167 12 273-19 422 1s247 2 389-4 256 19 409 1 238-8 380 7v427H0Z" fill="url(#sea)"/>
<g fill="none" stroke="#a4d7d3" stroke-linecap="round">
<path d="M70 555c164-27 270 18 422-4s277 17 450-4 291 17 574-2" opacity=".34" stroke-width="5"/>
<path d="M-20 655c191-35 322 30 500 1s334 20 532-7 314 25 626-5" opacity=".22" stroke-width="7"/>
<path d="M80 765c152-30 299 18 459-5s292 23 470-3 295 18 516-4" opacity=".14" stroke-width="8"/>
</g>
<path d="M424 468h112l118 432H275Z" fill="#f2d8ac" opacity=".08"/>
<g fill="none" stroke="#d9edf0" stroke-width="4" opacity=".38">
<path d="m1190 230 28-18 28 18"/>
<path d="m1250 270 23-15 24 15"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+336 -18
View File
@@ -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;
}
}
+213 -9
View File
@@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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(<App sound={sound} />)
render(<App settingsStore={settingsStore} sound={sound} />)
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<AppSettings | null>()
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<ReturnType<typeof deferred<void>>> = []
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<void>()
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*\{/)
})
})
+139 -10
View File
@@ -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<HTMLButtonElement>(null)
const saveInFlight = useRef(false)
const pendingSettingsSave = useRef<AppSettings | null>(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 (
<main className={`app phase-${timer.phase}`}>
<main className={`app phase-${timer.phase}`} data-testid="app" style={appStyle}>
<div className="orb orb-one" aria-hidden="true" />
<div className="orb orb-two" aria-hidden="true" />
@@ -115,9 +215,21 @@ function App({ sound = completionSound }: AppProps) {
<span className="brand-mark" aria-hidden="true">P</span>
<span>pomodoro</span>
</a>
<time className="clock" aria-label="Current time">
{formatClock(currentTime)}
</time>
<div className="topbar-actions">
<button
className="customize-button"
disabled={!settingsHydrated}
onClick={() => setSettingsOpen(true)}
ref={customizeButtonRef}
type="button"
>
<span aria-hidden="true"></span>
Customize
</button>
<time className="clock" aria-label="Current time">
{formatClock(currentTime)}
</time>
</div>
</header>
<section className="timer-card" aria-live="polite">
@@ -133,6 +245,7 @@ function App({ sound = completionSound }: AppProps) {
{PHASE_BUTTONS.map(({ phase, label }) => (
<button
className={timer.phase === phase ? 'active' : ''}
disabled={!settingsHydrated}
key={phase}
onClick={() => setTimer((current) => selectPhase(current, phase))}
type="button"
@@ -156,7 +269,12 @@ function App({ sound = completionSound }: AppProps) {
</output>
<div className="actions">
<button className="primary-action" onClick={handlePrimaryAction} type="button">
<button
className="primary-action"
disabled={!settingsHydrated}
onClick={handlePrimaryAction}
type="button"
>
{timer.status === 'running'
? 'Pause'
: timer.status === 'paused'
@@ -165,6 +283,7 @@ function App({ sound = completionSound }: AppProps) {
</button>
<button
className="secondary-action"
disabled={!settingsHydrated}
onClick={() => setTimer((current) => resetTimer(current))}
type="button"
>
@@ -183,12 +302,22 @@ function App({ sound = completionSound }: AppProps) {
</section>
<footer>
<span>25 min focus</span>
<span>{settings.focusMinutes} min focus</span>
<span aria-hidden="true"></span>
<span>5 min short break</span>
<span>{settings.shortBreakMinutes} min short break</span>
<span aria-hidden="true"></span>
<span>15 min long break</span>
<span>{settings.longBreakMinutes} min long break</span>
</footer>
{settingsOpen && (
<SettingsPanel
onChange={applySettings}
onClose={closeSettings}
onReset={() => applySettings(DEFAULT_SETTINGS)}
persistenceError={persistenceState === 'error'}
settings={settings}
/>
)}
</main>
)
}
+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,
}
}
+60
View File
@@ -7,6 +7,7 @@ import {
selectPhase,
startTimer,
tickTimer,
updateDurations,
} from './timer'
describe('pomodoro timer model', () => {
@@ -97,4 +98,63 @@ describe('pomodoro timer model', () => {
status: 'idle',
})
})
it('uses custom durations for the complete focus and break cycle', () => {
const durations = {
focus: 30 * 60_000,
shortBreak: 7 * 60_000,
longBreak: 20 * 60_000,
}
const timer = createTimer(durations)
const completed = tickTimer(startTimer(timer, 0), durations.focus)
expect(timer.remainingMs).toBe(durations.focus)
expect(completed.remainingMs).toBe(durations.shortBreak)
expect(completed.durations).toEqual(durations)
})
it('applies changed durations immediately when the timer is not running', () => {
const durations = {
focus: 40 * 60_000,
shortBreak: 8 * 60_000,
longBreak: 25 * 60_000,
}
const timer = selectPhase(createTimer(), 'shortBreak')
expect(updateDurations(timer, durations)).toMatchObject({
remainingMs: durations.shortBreak,
durations,
})
})
it('preserves the current countdown when durations change while running', () => {
const running = startTimer(createTimer(), 1_000)
const durations = {
focus: 40 * 60_000,
shortBreak: 8 * 60_000,
longBreak: 25 * 60_000,
}
expect(updateDurations(running, durations)).toMatchObject({
remainingMs: DURATIONS.focus,
endAt: 1_000 + DURATIONS.focus,
durations,
})
})
it('preserves the current countdown when durations change while paused', () => {
const paused = pauseTimer(startTimer(createTimer(), 1_000), 61_000)
const durations = {
focus: 40 * 60_000,
shortBreak: 8 * 60_000,
longBreak: 25 * 60_000,
}
expect(updateDurations(paused, durations)).toMatchObject({
status: 'paused',
remainingMs: DURATIONS.focus - 60_000,
endAt: null,
durations,
})
})
})
+24 -7
View File
@@ -1,7 +1,8 @@
export type TimerPhase = 'focus' | 'shortBreak' | 'longBreak'
export type TimerStatus = 'idle' | 'running' | 'paused'
export type TimerDurations = Record<TimerPhase, number>
export const DURATIONS: Record<TimerPhase, number> = {
export const DURATIONS: TimerDurations = {
focus: 25 * 60 * 1_000,
shortBreak: 5 * 60 * 1_000,
longBreak: 15 * 60 * 1_000,
@@ -13,15 +14,17 @@ export interface TimerState {
remainingMs: number
completedFocusSessions: number
endAt: number | null
durations: TimerDurations
}
export function createTimer(): TimerState {
export function createTimer(durations: TimerDurations = DURATIONS): TimerState {
return {
phase: 'focus',
status: 'idle',
remainingMs: DURATIONS.focus,
remainingMs: durations.focus,
completedFocusSessions: 0,
endAt: null,
durations,
}
}
@@ -54,7 +57,7 @@ export function resetTimer(timer: TimerState): TimerState {
return {
...timer,
status: 'idle',
remainingMs: DURATIONS[timer.phase],
remainingMs: timer.durations[timer.phase],
endAt: null,
}
}
@@ -67,11 +70,23 @@ export function selectPhase(
...timer,
phase,
status: 'idle',
remainingMs: DURATIONS[phase],
remainingMs: timer.durations[phase],
endAt: null,
}
}
export function updateDurations(
timer: TimerState,
durations: TimerDurations,
): TimerState {
return {
...timer,
durations,
remainingMs:
timer.status === 'idle' ? durations[timer.phase] : timer.remainingMs,
}
}
export function tickTimer(timer: TimerState, now: number): TimerState {
if (timer.status !== 'running' || timer.endAt === null) {
return timer
@@ -94,17 +109,19 @@ function completeSegment(timer: TimerState): TimerState {
return {
phase,
status: 'idle',
remainingMs: DURATIONS[phase],
remainingMs: timer.durations[phase],
completedFocusSessions,
endAt: null,
durations: timer.durations,
}
}
return {
phase: 'focus',
status: 'idle',
remainingMs: DURATIONS.focus,
remainingMs: timer.durations.focus,
completedFocusSessions: timer.completedFocusSessions,
endAt: null,
durations: timer.durations,
}
}