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