From 2d03b17c2993833acdc87176358c337768556e0f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 3 Oct 2026 19:20:18 +0000
Subject: [PATCH] Timer: confirm before finish and discard, reset restarts
instead of going home, Enter answers sheets
Co-Authored-By: Claude Sonnet 5.5
Claude-Session: https://claude.ai/code/session_015nrcxXata36VX6BEcq78G6
---
app/timer/page.tsx | 41 +++++++++++++++++++++-----
components/ActiveTimerDock.tsx | 16 +++++++++-
components/ConfirmSheet.tsx | 13 +++++++--
components/LogTimeSheet.tsx | 11 ++++++-
components/SessionLogModal.tsx | 12 +++++++-
components/TimerHotkeys.tsx | 53 ++++++++++++++++++++++++----------
lib/use-enter-key.ts | 34 ++++++++++++++++++++++
readmedesign.md | 22 ++++++++++----
8 files changed, 168 insertions(+), 34 deletions(-)
create mode 100644 lib/use-enter-key.ts
diff --git a/app/timer/page.tsx b/app/timer/page.tsx
index 0bdad2d..7706629 100644
--- a/app/timer/page.tsx
+++ b/app/timer/page.tsx
@@ -14,6 +14,7 @@ import {
isLoggableDuration,
} from '@/lib/session-safety';
import PendingSessionLogSheet from '@/components/PendingSessionLogSheet';
+import ConfirmSheet from '@/components/ConfirmSheet';
import LoadingIndicator from '@/components/LoadingIndicator';
import { useNotice } from '@/components/Notice';
import StudyFan from '@/components/StudyFan';
@@ -124,6 +125,9 @@ export default function TimerPage() {
stop,
} = useTimer();
+ /* Finish and Discard both end the sitting, so both ask first. */
+ const [asking, setAsking] = useState<'finish' | 'discard' | null>(null);
+
const { courses } = useCourses();
const { tasks } = useTasks();
const { notify } = useNotice();
@@ -344,6 +348,7 @@ export default function TimerPage() {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
+ if (asking) return;
if (e.key === ' ') {
e.preventDefault();
// On a break the one thing Space can mean is ending it. Pausing a
@@ -354,7 +359,7 @@ export default function TimerPage() {
else pause();
} else if (e.key === 'f' || e.key === 'F') {
e.preventDefault();
- handleStop();
+ setAsking('finish');
} else if (e.key === 'Escape') {
if (immersive) toggleImmersive();
else router.push('/dashboard');
@@ -362,7 +367,7 @@ export default function TimerPage() {
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
- }, [endBreak, handleStop, immersive, liveActive, onBreak, pause, resume, router, toggleImmersive]);
+ }, [asking, endBreak, immersive, liveActive, onBreak, pause, resume, router, toggleImmersive]);
if (!hydrated || (!course && !pendingLog)) {
return (
@@ -636,15 +641,12 @@ export default function TimerPage() {
);
- const finishButton = secondary('Finish', handleStop);
+ const finishButton = secondary('Finish', () => setAsking('finish'));
const discardButton = (
+ setAsking(null)}
+ onConfirm={() => {
+ setAsking(null);
+ handleStop();
+ }}
+ />
+ setAsking(null)}
+ onConfirm={() => {
+ setAsking(null);
+ cancel();
+ router.replace('/dashboard');
+ }}
+ />
{pictureSheet}
diff --git a/components/ActiveTimerDock.tsx b/components/ActiveTimerDock.tsx
index 84eb8cb..a8975dd 100644
--- a/components/ActiveTimerDock.tsx
+++ b/components/ActiveTimerDock.tsx
@@ -6,6 +6,7 @@ import { useCourses } from '@/lib/data-hooks';
import { breakStartsAt, useTimer } from '@/lib/timer-context';
import { formatHHMMSS, formatHM, resolveTint } from '@/lib/utils';
import { hasEarlierBlock, stretchFace } from '@/lib/timer-face';
+import ConfirmSheet from './ConfirmSheet';
export default function ActiveTimerDock() {
const router = useRouter();
@@ -16,6 +17,7 @@ export default function ActiveTimerDock() {
is held. What Break would do changes with the length of the hold, and
the button's label has to keep up with it. */
const [, setHeldRedraw] = useState(0);
+ const [confirmingFinish, setConfirmingFinish] = useState(false);
const held = Boolean(active?.isPaused);
useEffect(() => {
if (!held) return;
@@ -61,11 +63,13 @@ export default function ActiveTimerDock() {
}
function stopAndLog() {
+ setConfirmingFinish(false);
stop();
router.push('/timer');
}
return (
+ <>
setConfirmingFinish(true)}
aria-label="Stop the timer and log the session"
title="Finish (K)"
className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-transparent"
@@ -206,5 +210,15 @@ export default function ActiveTimerDock() {
+ setConfirmingFinish(false)}
+ onConfirm={stopAndLog}
+ />
+ >
);
}
diff --git a/components/ConfirmSheet.tsx b/components/ConfirmSheet.tsx
index ce0f1f1..7e0873d 100644
--- a/components/ConfirmSheet.tsx
+++ b/components/ConfirmSheet.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { ButtonSpinner } from './LoadingIndicator';
import { useLeaving } from './Leaving';
+import { useEnterKey } from '@/lib/use-enter-key';
interface Props {
open: boolean;
@@ -59,10 +60,13 @@ export default function ConfirmSheet({
const [shown, leaving] = useLeaving(open);
- if (!shown) return null;
-
const armed = !busy && (!requirePhrase || typed.trim().toLowerCase() === requirePhrase.toLowerCase());
+ // The typed-phrase field already confirms on its own Enter.
+ useEnterKey(open && !requirePhrase && armed, onConfirm);
+
+ if (!shown) return null;
+
return (
diff --git a/components/LogTimeSheet.tsx b/components/LogTimeSheet.tsx
index a402b1b..828604c 100644
--- a/components/LogTimeSheet.tsx
+++ b/components/LogTimeSheet.tsx
@@ -5,6 +5,7 @@ import type { Course, Task } from '@/lib/data';
import { addSessionOptimistic } from '@/lib/data-hooks';
import { isoDate } from '@/lib/utils';
import { useLeaving } from './Leaving';
+import { useEnterKey } from '@/lib/use-enter-key';
import DatePicker from './DatePicker';
import { ButtonSpinner } from './LoadingIndicator';
@@ -68,6 +69,7 @@ export default function LogTimeSheet({
}, [open, onClose]);
const [shown, leaving] = useLeaving(open);
+ useEnterKey(open, () => void save());
if (!shown) return null;
const typedMinutes = typed.trim() ? Math.round(Number(typed)) : null;
@@ -212,7 +214,14 @@ export default function LogTimeSheet({
onClick={save}
className="flex-1 rounded-[10px] bg-primary py-3.5 text-sm font-medium text-primary-contrast disabled:opacity-40"
>
- {saving ? : 'Save'}
+ {saving ? (
+
+ ) : (
+ <>
+ Save
+ ↵
+ >
+ )}
diff --git a/components/SessionLogModal.tsx b/components/SessionLogModal.tsx
index e745871..4dd8af8 100644
--- a/components/SessionLogModal.tsx
+++ b/components/SessionLogModal.tsx
@@ -11,6 +11,7 @@ import TallyMarks from '@/components/progression/TallyMarks';
import { ButtonSpinner } from './LoadingIndicator';
import { useLeaving } from './Leaving';
import { usePreferences } from '@/lib/preferences';
+import { useEnterKey } from '@/lib/use-enter-key';
// Quick-reflection tag chips. Tapping appends `#tag` into the note so the
// data shape stays the same, no schema migration needed for this flourish.
@@ -124,6 +125,8 @@ export default function SessionLogModal({
}, [open]);
const [shown, leaving] = useLeaving(open);
+ const canSaveNow = open && isLoggableDuration(durationSeconds) && !saving;
+ useEnterKey(canSaveNow, () => onSave(note, markDone, keep, practice));
if (!shown || !course) return null;
const canSave = isLoggableDuration(durationSeconds) && !saving;
@@ -530,7 +533,14 @@ export default function SessionLogModal({
className="flex-1 min-h-[50px] py-3.5 rounded-[10px] bg-primary text-primary-contrast text-sm font-medium inline-flex items-center justify-center gap-2 disabled:opacity-35"
>
- {saving ? Saving… : 'Save'}
+ {saving ? (
+ Saving…
+ ) : (
+ <>
+ Save
+ ↵
+ >
+ )}
diff --git a/components/TimerHotkeys.tsx b/components/TimerHotkeys.tsx
index b98d53a..109a8c0 100644
--- a/components/TimerHotkeys.tsx
+++ b/components/TimerHotkeys.tsx
@@ -51,8 +51,9 @@ function blinkPage() {
export default function TimerHotkeys() {
const router = useRouter();
- const { active, onBreak, endBreak, startBreak, pause, resume, stop, cancel } = useTimerState();
+ const { active, onBreak, endBreak, startBreak, pause, resume, stop, cancel, start } = useTimerState();
const [confirmingReset, setConfirmingReset] = useState(false);
+ const [confirmingFinish, setConfirmingFinish] = useState(false);
useEffect(() => {
if (!active) return;
@@ -82,9 +83,9 @@ export default function TimerHotkeys() {
blinkPage();
startBreak();
} else if (key === 'k') {
+ // Ends the sitting, so it asks first.
event.preventDefault();
- stop();
- if (!isLogSheetMounted()) router.push('/timer');
+ setConfirmingFinish(true);
} else if (key === 'r') {
// Throws the sitting away, so it asks first. The key only opens the
// question; the sheet's own button is what does it.
@@ -95,20 +96,40 @@ export default function TimerHotkeys() {
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
- }, [active, onBreak, endBreak, startBreak, pause, resume, stop, router]);
+ }, [active, onBreak, endBreak, startBreak, pause, resume]);
return (
- setConfirmingReset(false)}
- onConfirm={() => {
- setConfirmingReset(false);
- cancel();
- }}
- />
+ <>
+ setConfirmingReset(false)}
+ onConfirm={() => {
+ setConfirmingReset(false);
+ // Cancel and start in one go, so no screen sees the gap between
+ // them and sends the reader home.
+ if (!active) return;
+ const { courseId, taskId, targetSeconds } = active;
+ cancel();
+ start(courseId, taskId, targetSeconds);
+ }}
+ />
+ setConfirmingFinish(false)}
+ onConfirm={() => {
+ setConfirmingFinish(false);
+ stop();
+ if (!isLogSheetMounted()) router.push('/timer');
+ }}
+ />
+ >
);
}
diff --git a/lib/use-enter-key.ts b/lib/use-enter-key.ts
new file mode 100644
index 0000000..fcf0708
--- /dev/null
+++ b/lib/use-enter-key.ts
@@ -0,0 +1,34 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+/**
+ * Let Enter do what a sheet's main button does.
+ *
+ * Plain Enter from anywhere on the sheet, except where Enter already means
+ * something: on a button or link it presses that control, and in a note it
+ * starts a new line, so a note takes ⌘/Ctrl+Enter instead. Held keys and
+ * IME compositions are ignored.
+ */
+export function useEnterKey(active: boolean, onEnter: () => void) {
+ const handler = useRef(onEnter);
+ useEffect(() => {
+ handler.current = onEnter;
+ });
+
+ useEffect(() => {
+ if (!active) return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== 'Enter' || event.repeat || event.isComposing || event.defaultPrevented) return;
+ if (event.shiftKey || event.altKey) return;
+ const target = event.target as HTMLElement | null;
+ if (target?.closest('button, a, select, summary')) return;
+ const inNote = !!target?.closest('textarea, [contenteditable=true]');
+ if (inNote && !(event.metaKey || event.ctrlKey)) return;
+ event.preventDefault();
+ handler.current();
+ }
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [active]);
+}
diff --git a/readmedesign.md b/readmedesign.md
index 30ac560..48f9091 100644
--- a/readmedesign.md
+++ b/readmedesign.md
@@ -2332,12 +2332,24 @@ 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.
+### 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
+save sheet (the log sheet, Log time) answers to **Enter**, through
+`lib/use-enter-key.ts`: plain Enter from anywhere on the sheet except a button
+or link (which it presses) or a note (where Enter is a new line, so a note
+takes ⌘/Ctrl+Enter). The main button carries a faint ↵ after its label to say
+so, in `.key-hint`, so it is hidden on a touch screen. A sheet that asks for a
+typed phrase keeps its own Enter on the field.
+
### Keys for the running clock
Four more, and they work from every screen rather than from the timer. `R`
-resets the sitting: it opens the same `ConfirmSheet` every destructive
-question uses ("Reset this sitting? The time so far won't be saved."), and
-only the sheet's Reset button throws the clock away, so a stray key never
-does. The timer screen's key hint names it too ("R reset"), since that is
+starts the sitting over: it opens the same `ConfirmSheet` every destructive
+question uses ("Start this sitting over? The time so far won't be saved."),
+and only the sheet's Reset button throws the clock away, so a stray key never
+does. Confirming cancels and starts again in one step on the same course,
+task and block length, so the reader stays on the timer at zero rather than
+being sent home. The timer screen's key hint names it too ("R reset"), since that is
where a reader looks for the keys. `B` takes a break; on a held block it
counts the pause as the break (see "The pause"), and on a break it does
nothing, since ending one is `P`'s. It used to be the timer screen's own
@@ -2348,7 +2360,7 @@ back to their book. The timer's own keys (Space, F) only exist on the
timer screen, which is the one screen a reader mid-chapter is least likely to
be looking at, so stopping meant finding the dock with a mouse. `P` and `K`
reach the clock from wherever they are: `P` holds it or lets it go, and means
-**end break** while a break runs (the next block starts running); `K` finishes the sitting and the log sheet
+**end break** while a break runs (the next block starts running); `K` asks "Finish this sitting?" and, once answered, finishes it and the log sheet
opens on the spot, because `PageShell` already carries one on every tab. On
the few screens that carry neither sheet nor shell, `K` opens the timer
instead, so the key never ends a sitting with nothing to show for it.