[verified] feat: auto-start next timer

This commit is contained in:
2026-07-23 00:27:05 +03:00
parent 723b4a221d
commit 6baa13dc90
9 changed files with 236 additions and 14 deletions
+1
View File
@@ -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
+42
View File
@@ -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);
+54 -1
View File
@@ -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()
+5 -4
View File
@@ -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) {
+12
View File
@@ -113,6 +113,18 @@ function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset
/>
</label>
</div>
<label className="timer-toggle">
<input
aria-label="Automatically start next timer"
checked={settings.autoStartNext}
onChange={(event) => update('autoStartNext', event.target.checked)}
type="checkbox"
/>
<span>
<strong>Automatically start next timer</strong>
<small>Continue from focus to break, then back to focus.</small>
</span>
</label>
</section>
<section className="settings-section">
+11
View File
@@ -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)
})
+6
View File
@@ -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
+85
View File
@@ -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()
+20 -9
View File
@@ -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 {