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 = ( 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.