From 6baa13dc90bea6d2a3faeb48b2f7f69cb0d0f10d Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Thu, 23 Jul 2026 00:27:05 +0300 Subject: [PATCH] [verified] feat: auto-start next timer --- README.md | 1 + src/App.css | 42 ++++++++++++ src/App.test.tsx | 55 +++++++++++++++- src/App.tsx | 9 +-- src/features/settings/SettingsPanel.tsx | 12 ++++ src/features/settings/settings.test.ts | 11 ++++ src/features/settings/settings.ts | 6 ++ src/features/timer/timer.test.ts | 85 +++++++++++++++++++++++++ src/features/timer/timer.ts | 29 ++++++--- 9 files changed, 236 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 76e91aa..60dd40e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite. ## Stage 2 - Custom focus, short-break, and long-break durations +- Automatic focus-to-break sequencing, enabled by default with an opt-out setting - Six built-in backgrounds: three gradients and three local illustrated scenes - Adjustable background overlay and text color - Four clock and timer font styles diff --git a/src/App.css b/src/App.css index eedd06a..32b7606 100644 --- a/src/App.css +++ b/src/App.css @@ -434,6 +434,48 @@ h1 { box-shadow: 0 0 0 3px rgba(145, 117, 247, 0.14); } +.timer-toggle { + display: flex; + align-items: flex-start; + gap: 11px; + margin-top: 16px; + padding: 13px 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 13px; + color: rgba(255, 255, 255, 0.72); + background: rgba(255, 255, 255, 0.04); + cursor: pointer; +} + +.timer-toggle input { + width: 17px; + height: 17px; + margin: 1px 0 0; + accent-color: #8d6bff; +} + +.timer-toggle span { + display: grid; + gap: 3px; +} + +.timer-toggle strong { + color: #fff; + font-size: 0.78rem; + font-weight: 600; +} + +.timer-toggle small { + color: rgba(255, 255, 255, 0.44); + font-size: 0.68rem; + line-height: 1.4; +} + +.timer-toggle:focus-within { + 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); diff --git a/src/App.test.tsx b/src/App.test.tsx index 81ec66d..be8b712 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -93,7 +93,7 @@ describe('Pomodoro app', () => { expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() }) - it('announces completion and switches to a short break', async () => { + it('announces completion and automatically starts the short break', async () => { const sound = await renderHydratedApp() fireEvent.click(screen.getByRole('button', { name: 'Start' })) @@ -103,6 +103,43 @@ describe('Pomodoro app', () => { expect(screen.getByRole('heading', { name: 'Short break' })).toBeInTheDocument() expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00') expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument() + }) + + it('plays one completion sound when a delayed update spans multiple segments', async () => { + const sound = await renderHydratedApp(new MemorySettingsStore({ + ...DEFAULT_SETTINGS, + focusMinutes: 1, + shortBreakMinutes: 1, + longBreakMinutes: 1, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + act(() => { + vi.setSystemTime(new Date('2026-07-22T10:10:01')) + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(sound.play).toHaveBeenCalledOnce() + expect(screen.getByRole('heading', { name: 'Focus session' })).toBeInTheDocument() + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('00:59') + expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument() + }) + + it('waits for manual start when automatic transitions are disabled', async () => { + const sound = await renderHydratedApp(new MemorySettingsStore({ + ...DEFAULT_SETTINGS, + autoStartNext: false, + })) + + fireEvent.click(screen.getByRole('button', { name: 'Start' })) + act(() => vi.advanceTimersByTime(25 * 60_000)) + + expect(screen.getByRole('heading', { name: 'Short break' })).toBeInTheDocument() + expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00') + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument() + expect(sound.play).toHaveBeenCalledOnce() }) it('lets the user select focus and break segments', async () => { @@ -132,6 +169,22 @@ describe('Pomodoro app', () => { expect(screen.getByText('25 min long break')).toBeInTheDocument() }) + it('lets the user disable automatic timer transitions', async () => { + const store = new MemorySettingsStore() + await renderHydratedApp(store) + + fireEvent.click(screen.getByRole('button', { name: 'Customize' })) + const autoStart = screen.getByRole('checkbox', { name: 'Automatically start next timer' }) + expect(autoStart).toBeChecked() + + fireEvent.click(autoStart) + expect(autoStart).not.toBeChecked() + await act(async () => { + await Promise.resolve() + }) + expect(store.settings?.autoStartNext).toBe(false) + }) + it('applies background, overlay, font, and text color customization', async () => { await renderHydratedApp() diff --git a/src/App.tsx b/src/App.tsx index c468deb..6ae0ec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -134,17 +134,18 @@ function App({ const updateTimer = useCallback(() => { setTimer((current) => { - const next = tickTimer(current, Date.now()) + const now = Date.now() + const next = tickTimer(current, now, settings.autoStartNext) if ( current.status === 'running' && - next.status === 'idle' && - current.phase !== next.phase + current.endAt !== null && + now >= current.endAt ) { completionPending.current = true } return next }) - }, []) + }, [settings.autoStartNext]) useEffect(() => { if (completionPending.current) { diff --git a/src/features/settings/SettingsPanel.tsx b/src/features/settings/SettingsPanel.tsx index d6613e2..ee07f4e 100644 --- a/src/features/settings/SettingsPanel.tsx +++ b/src/features/settings/SettingsPanel.tsx @@ -113,6 +113,18 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset /> +
diff --git a/src/features/settings/settings.test.ts b/src/features/settings/settings.test.ts index 17b3e9e..5e994e5 100644 --- a/src/features/settings/settings.test.ts +++ b/src/features/settings/settings.test.ts @@ -9,6 +9,17 @@ import { import { IndexedDbSettingsStore } from './settings-store' describe('application settings', () => { + it('automatically starts the next timer by default', () => { + expect(DEFAULT_SETTINGS.autoStartNext).toBe(true) + }) + + it('preserves an explicit auto-start opt-out during normalization', () => { + expect(normalizeSettings({ + ...DEFAULT_SETTINGS, + autoStartNext: false, + }).autoStartNext).toBe(false) + }) + it('uses an overlay default aligned with the slider step', () => { expect(DEFAULT_SETTINGS.overlayOpacity).toBe(0.2) }) diff --git a/src/features/settings/settings.ts b/src/features/settings/settings.ts index 2cf352c..eb416b4 100644 --- a/src/features/settings/settings.ts +++ b/src/features/settings/settings.ts @@ -17,6 +17,7 @@ export interface AppSettings { focusMinutes: number shortBreakMinutes: number longBreakMinutes: number + autoStartNext: boolean fontId: FontId backgroundId: BackgroundId overlayOpacity: number @@ -27,6 +28,7 @@ export const DEFAULT_SETTINGS: AppSettings = { focusMinutes: 25, shortBreakMinutes: 5, longBreakMinutes: 15, + autoStartNext: true, fontId: 'space', backgroundId: 'violet', overlayOpacity: 0.2, @@ -53,6 +55,10 @@ export function normalizeSettings(value: unknown): AppSettings { 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), + autoStartNext: + typeof candidate.autoStartNext === 'boolean' + ? candidate.autoStartNext + : DEFAULT_SETTINGS.autoStartNext, fontId: isOneOf(candidate.fontId, FONT_IDS) ? candidate.fontId : DEFAULT_SETTINGS.fontId, backgroundId: isOneOf(candidate.backgroundId, BACKGROUND_IDS) ? candidate.backgroundId diff --git a/src/features/timer/timer.test.ts b/src/features/timer/timer.test.ts index a088395..c6306ae 100644 --- a/src/features/timer/timer.test.ts +++ b/src/features/timer/timer.test.ts @@ -51,6 +51,91 @@ describe('pomodoro timer model', () => { }) }) + it('automatically starts the short break at the focus deadline', () => { + const startedAt = 1_000 + const running = startTimer(createTimer(), startedAt) + const completed = tickTimer(running, startedAt + DURATIONS.focus, true) + + expect(completed).toMatchObject({ + phase: 'shortBreak', + status: 'running', + remainingMs: DURATIONS.shortBreak, + completedFocusSessions: 1, + endAt: startedAt + DURATIONS.focus + DURATIONS.shortBreak, + }) + }) + + it('derives auto-started break time from the prior deadline after a delayed tick', () => { + const running = startTimer(createTimer(), 0) + const delayedBy = 60_000 + const completed = tickTimer(running, DURATIONS.focus + delayedBy, true) + + expect(completed).toMatchObject({ + phase: 'shortBreak', + status: 'running', + remainingMs: DURATIONS.shortBreak - delayedBy, + endAt: DURATIONS.focus + DURATIONS.shortBreak, + }) + }) + + it('catches up through a break and continues into the next focus', () => { + const delayedIntoFocus = 60_000 + const now = DURATIONS.focus + DURATIONS.shortBreak + delayedIntoFocus + const caughtUp = tickTimer(startTimer(createTimer(), 0), now, true) + + expect(caughtUp).toMatchObject({ + phase: 'focus', + status: 'running', + remainingMs: DURATIONS.focus - delayedIntoFocus, + completedFocusSessions: 1, + endAt: DURATIONS.focus + DURATIONS.shortBreak + DURATIONS.focus, + }) + }) + + it('automatically starts a long break after the fourth focus', () => { + const fourthFocus = { + ...createTimer(), + completedFocusSessions: 3, + } + const completed = tickTimer( + startTimer(fourthFocus, 0), + DURATIONS.focus, + true, + ) + + expect(completed).toMatchObject({ + phase: 'longBreak', + status: 'running', + remainingMs: DURATIONS.longBreak, + completedFocusSessions: 4, + endAt: DURATIONS.focus + DURATIONS.longBreak, + }) + }) + + it('catches up after a week without overflowing the call stack', () => { + const oneMinute = 60_000 + const durations = { + focus: oneMinute, + shortBreak: oneMinute, + longBreak: oneMinute, + } + const oneWeek = 7 * 24 * 60 * oneMinute + + const caughtUp = tickTimer( + startTimer(createTimer(durations), 0), + oneWeek, + true, + ) + + expect(caughtUp).toMatchObject({ + phase: 'focus', + status: 'running', + remainingMs: oneMinute, + completedFocusSessions: 5_040, + endAt: oneWeek + oneMinute, + }) + }) + it('moves to a long break after every fourth focus session', () => { let timer = createTimer() diff --git a/src/features/timer/timer.ts b/src/features/timer/timer.ts index 6764b48..b408a3e 100644 --- a/src/features/timer/timer.ts +++ b/src/features/timer/timer.ts @@ -87,17 +87,28 @@ export function updateDurations( } } -export function tickTimer(timer: TimerState, now: number): TimerState { - if (timer.status !== 'running' || timer.endAt === null) { - return timer +export function tickTimer( + timer: TimerState, + now: number, + autoStartNext = false, +): TimerState { + let current = timer + + while (current.status === 'running' && current.endAt !== null) { + const remainingMs = Math.max(0, current.endAt - now) + if (remainingMs > 0) { + return { ...current, remainingMs } + } + + const completed = completeSegment(current) + if (!autoStartNext) { + return completed + } + + current = startTimer(completed, current.endAt) } - const remainingMs = Math.max(0, timer.endAt - now) - if (remainingMs > 0) { - return { ...timer, remainingMs } - } - - return completeSegment(timer) + return current } function completeSegment(timer: TimerState): TimerState {