From 1acbf354c00ebf55b404af4f539eeb12bf141d6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:43:27 +0000 Subject: [PATCH] Timer: say why whenever it ends a sitting by itself or drops one with nothing to log Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015nrcxXata36VX6BEcq78G6 --- CLAUDE.md | 2 + app/layout.tsx | 2 + app/timer/page.tsx | 3 ++ components/PendingSessionLogSheet.tsx | 4 +- components/TimerEndedNotice.tsx | 12 ++++++ lib/timer-context.tsx | 9 +++++ lib/timer-ended.ts | 57 +++++++++++++++++++++++++++ readmedesign.md | 11 ++++++ 8 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 components/TimerEndedNotice.tsx create mode 100644 lib/timer-ended.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0f07257..38c37ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,8 @@ Saves searching. If one of these moves, fix the line here in the same commit. fills) is `lib/ocean/pen.ts`. Anything new drawn in the sea uses it. - **Audio hooks** are `lib/use-*.ts` (`use-aquarium-sound`). Sounds are synthesised in Web Audio, not shipped as files, so they work on iOS and offline. +- **Why the timer stopped on its own** is `lib/timer-ended.ts` (`announceEnded`). + Any new way for the clock to end or drop a sitting calls it. - **Timer state and actions** (`pause`, `stop`, `cancel`, breaks) are in `lib/timer-context.tsx`; read them with `useTimerState()`. diff --git a/app/layout.tsx b/app/layout.tsx index 3c96799..36ca7fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import ClaudeSheetProvider from '@/components/claude/ClaudeSheet'; import SWRRoot from '@/components/SWRRoot'; import TimerDocumentTitle from '@/components/TimerDocumentTitle'; import TimerHotkeys from '@/components/TimerHotkeys'; +import TimerEndedNotice from '@/components/TimerEndedNotice'; import PaperDoodle from '@/components/notebook/PaperDoodle'; import SmoothScroll from '@/components/SmoothScroll'; import TapSounds from '@/components/TapSounds'; @@ -170,6 +171,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) component: they bind nothing unless a sitting is running. */} + {children} diff --git a/app/timer/page.tsx b/app/timer/page.tsx index 7706629..139ed38 100644 --- a/app/timer/page.tsx +++ b/app/timer/page.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/session-safety'; import PendingSessionLogSheet from '@/components/PendingSessionLogSheet'; import ConfirmSheet from '@/components/ConfirmSheet'; +import { announceEnded } from '@/lib/timer-ended'; import LoadingIndicator from '@/components/LoadingIndicator'; import { useNotice } from '@/components/Notice'; import StudyFan from '@/components/StudyFan'; @@ -265,6 +266,7 @@ export default function TimerPage() { } const durationSeconds = clampSessionSeconds(result.durationSeconds); if (!isLoggableDuration(durationSeconds)) { + announceEnded('That sitting was too short to log.'); clearPendingLog(); router.replace('/dashboard'); } @@ -285,6 +287,7 @@ export default function TimerPage() { return; } if (timerCourseId && courses.length > 0 && !course) { + announceEnded('The timer was for a course that no longer exists, so it was cleared.'); cancel(); clearPendingLog(); router.replace('/dashboard'); diff --git a/components/PendingSessionLogSheet.tsx b/components/PendingSessionLogSheet.tsx index bfc2c44..47c4192 100644 --- a/components/PendingSessionLogSheet.tsx +++ b/components/PendingSessionLogSheet.tsx @@ -1,5 +1,6 @@ 'use client'; +import { announceEnded } from '@/lib/timer-ended'; import { useEffect, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import { @@ -168,6 +169,7 @@ export default function PendingSessionLogSheet() { useEffect(() => { if (pendingLog && !coursesLoading && courses.length > 0 && !course) { + announceEnded('That sitting was for a course that no longer exists, so it was cleared.'); clearPendingLog(); } }, [clearPendingLog, course, courses.length, coursesLoading, pendingLog]); @@ -383,7 +385,7 @@ function LogNotice({ log.recoveryReason === 'away' ? `The timer on ${courseCode} was left running while the page was closed, so it stopped where it was last open.` : log.recoveryReason === 'break' - ? `The break ran past 45 minutes, so the ${courseCode} session was closed where it began.` + ? `The break hit its 45 minute limit, so the ${courseCode} sitting was closed there. Time on break is kept apart and never counted as study.` : log.recoveryReason === 'max' ? `The ${courseCode} session reached the 18 hour limit and stopped there.` : log.recoveryReason === 'pause' diff --git a/components/TimerEndedNotice.tsx b/components/TimerEndedNotice.tsx new file mode 100644 index 0000000..02f86f6 --- /dev/null +++ b/components/TimerEndedNotice.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useEffect } from 'react'; +import { useNotice } from './Notice'; +import { onEnded } from '@/lib/timer-ended'; + +/** Reads the timer's reasons for stopping out through the notice slip. */ +export default function TimerEndedNotice() { + const { notify } = useNotice(); + useEffect(() => onEnded(notify), [notify]); + return null; +} diff --git a/lib/timer-context.tsx b/lib/timer-context.tsx index 45f3713..b20babc 100644 --- a/lib/timer-context.tsx +++ b/lib/timer-context.tsx @@ -23,6 +23,7 @@ import { cancelChime, flushChime, primeChime, ringChime, scheduleChime } from '. import { playSound } from './sounds'; import { logSessionFollowed } from './progression/log'; import { idleTripped, quietPoint } from './timer-idle'; +import { announceEnded, endedMessage } from './timer-ended'; import { isoDate } from './utils'; interface TimerState { @@ -808,6 +809,7 @@ function loadActiveSnapshot(): { active: TimerState | null; pendingLog: PendingT const idle = !staleRunning && !breakOverrun && !maxReached && idleTripped(parsed, now); if (idle) { const pendingLog = buildPendingLog(parsed, now, 'idle'); + announceEnded(endedMessage('idle', pendingLog != null)); saveActive(null); savePendingLog(pendingLog); return { active: null, pendingLog }; @@ -828,6 +830,7 @@ function loadActiveSnapshot(): { active: TimerState | null; pendingLog: PendingT ? 'pause' : 'max'; const pendingLog = buildPendingLog(parsed, stoppedAt, reason); + announceEnded(endedMessage(reason, pendingLog != null)); saveActive(null); savePendingLog(pendingLog); return { active: null, pendingLog }; @@ -983,6 +986,7 @@ export function TimerProvider({ children }: { children: React.ReactNode }) { cancelChime(); breakNoticeRef.current = null; const log = buildPendingLog(current, stoppedAt, reason); + announceEnded(endedMessage(reason, log != null)); activeRef.current = null; setActive(null); saveActive(null); @@ -1328,6 +1332,7 @@ export function TimerProvider({ children }: { children: React.ReactNode }) { return null; } const log = buildPendingLog(safeState, safeState.lastSeenAt, 'away'); + announceEnded(endedMessage('away', log != null)); activeRef.current = null; setActive(null); saveActive(null); @@ -1420,6 +1425,10 @@ export function TimerProvider({ children }: { children: React.ReactNode }) { pendingLogRef.current = result; setPendingLog(result); savePendingLog(result); + } else { + // Finishing something that was all break leaves no sheet to open, and + // used to leave the reader on a screen that quietly sent them home. + announceEnded('That sitting had no study time in it, so there was nothing to log.'); } return result; }, [recoverStaleRunningTimer]); diff --git a/lib/timer-ended.ts b/lib/timer-ended.ts new file mode 100644 index 0000000..9d4cc4f --- /dev/null +++ b/lib/timer-ended.ts @@ -0,0 +1,57 @@ +/** + * Why the clock stopped, said out loud. + * + * The timer ends itself in several ways the reader never asked for (a break + * past its ceiling, a page left closed, no sign of anybody, the 18 hour + * limit, a long pause) and drops a sitting with nothing in it without a + * sheet to say so. The log sheet explains the first kind once it opens; this + * is the slip of paper that says so at the moment it happens, and the only + * word at all for the second kind. + * + * The timer provider lives above the notice provider, and a sitting can be + * closed during hydration before anything is listening, so a message waits + * here until a listener takes it. + */ + +type Listener = (message: string) => void; + +const listeners = new Set(); +let waiting: string[] = []; + +export function announceEnded(message: string): void { + if (listeners.size === 0) { + waiting = [...waiting.slice(-2), message]; + return; + } + for (const listener of listeners) listener(message); +} + +export function onEnded(listener: Listener): () => void { + listeners.add(listener); + const backlog = waiting; + waiting = []; + for (const message of backlog) listener(message); + return () => { + listeners.delete(listener); + }; +} + +/** The reasons the timer closes a sitting by itself, as one plain sentence. */ +export function endedMessage( + reason: 'idle' | 'away' | 'max' | 'break' | 'pause' | undefined, + logged: boolean, +): string { + const why = + reason === 'break' + ? 'The break hit its 45 minute limit, so the sitting ended.' + : reason === 'away' + ? 'The page was closed for too long, so the timer stopped where it was last open.' + : reason === 'idle' + ? 'There was no sign of you for a while, so the timer stopped.' + : reason === 'max' + ? 'The sitting hit the 18 hour limit, so the timer stopped.' + : reason === 'pause' + ? 'The clock sat paused for over two hours, so the sitting ended.' + : 'The sitting ended.'; + return logged ? why : `${why} There was no study time in it to log.`; +} diff --git a/readmedesign.md b/readmedesign.md index 48f9091..e95cf4c 100644 --- a/readmedesign.md +++ b/readmedesign.md @@ -2332,6 +2332,17 @@ the one screen that looked like a spreadsheet. It is now a planner spread. there with the date or course already filled in. New task and `N` still open it at the top of the list. +### The clock says why it stopped +Whenever the timer ends a sitting the reader did not end (a break past its +45 minute limit, the page closed too long, no sign of anybody, the 18 hour +limit, a pause over two hours) or drops one with nothing in it to log (all +break, too short, a deleted course), it says so on the notice slip at that +moment, one plain sentence, through `lib/timer-ended.ts` +(`announceEnded`, read out by `components/TimerEndedNotice.tsx`). A sitting +that has hours in it also opens the log sheet, which gives the same reason in +its notice line. Nothing leaves the clock silently: a new way for the timer to +stop adds its sentence there in the same commit. + ### Enter answers the sheet Finishing a sitting (Finish, `F`, `K`, the dock's stop button) and discarding one both ask through `ConfirmSheet`, and so does reset. Every confirm and every