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 {