Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.

Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,6 +171,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
component: they bind nothing unless a sitting is running. */}
<TimerHotkeys />
<NoticeProvider>
<TimerEndedNotice />
<UndoProvider>
<ClaudeSheetProvider>{children}</ClaudeSheetProvider>
</UndoProvider>
Expand Down
3 changes: 3 additions & 0 deletions app/timer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand All @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion components/PendingSessionLogSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { announceEnded } from '@/lib/timer-ended';
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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'
Expand Down
12 changes: 12 additions & 0 deletions components/TimerEndedNotice.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions lib/timer-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down
57 changes: 57 additions & 0 deletions lib/timer-ended.ts
Original file line number Diff line number Diff line change
@@ -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<Listener>();
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.`;
}
11 changes: 11 additions & 0 deletions readmedesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading