[verified] feat: auto-start next timer
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user