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
41 changes: 34 additions & 7 deletions app/timer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -354,15 +359,15 @@ 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');
}
};
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 (
Expand Down Expand Up @@ -636,15 +641,12 @@ export default function TimerPage() {
</button>
);

const finishButton = secondary('Finish', handleStop);
const finishButton = secondary('Finish', () => setAsking('finish'));

const discardButton = (
<button
type="button"
onClick={() => {
cancel();
router.replace('/dashboard');
}}
onClick={() => setAsking('discard')}
style={night ? { color: '#CC8462' } : undefined}
className="h-11 rounded-[10px] px-4 text-[13px] font-medium text-warn transition-[background-color,transform] duration-200 ease-out hover:bg-warnTint active:scale-[0.97]"
>
Expand Down Expand Up @@ -1225,6 +1227,31 @@ export default function TimerPage() {
</p>
</div>

<ConfirmSheet
open={asking === 'finish'}
title="Finish this sitting?"
body="The clock stops and you'll log it next."
confirmLabel="Finish"
cancelLabel="Keep going"
onCancel={() => setAsking(null)}
onConfirm={() => {
setAsking(null);
handleStop();
}}
/>
<ConfirmSheet
open={asking === 'discard'}
title="Discard this sitting?"
body="The time so far won't be saved."
confirmLabel="Discard"
cancelLabel="Keep going"
onCancel={() => setAsking(null)}
onConfirm={() => {
setAsking(null);
cancel();
router.replace('/dashboard');
}}
/>
<PendingSessionLogSheet />
{pictureSheet}
</div>
Expand Down
16 changes: 15 additions & 1 deletion components/ActiveTimerDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -61,11 +63,13 @@ export default function ActiveTimerDock() {
}

function stopAndLog() {
setConfirmingFinish(false);
stop();
router.push('/timer');
}

return (
<>
<div
/* Floats over the page at every size. On a phone it sits at the top,
where the content's own 64px of head room leaves it a lane. On
Expand Down Expand Up @@ -192,7 +196,7 @@ export default function ActiveTimerDock() {
)}
<button
type="button"
onClick={stopAndLog}
onClick={() => 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"
Expand All @@ -206,5 +210,15 @@ export default function ActiveTimerDock() {
</div>
</div>
</div>
<ConfirmSheet
open={confirmingFinish}
title="Finish this sitting?"
body="The clock stops and you'll log it next."
confirmLabel="Finish"
cancelLabel="Keep going"
onCancel={() => setConfirmingFinish(false)}
onConfirm={stopAndLog}
/>
</>
);
}
13 changes: 10 additions & 3 deletions components/ConfirmSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className={`sheet-lift fixed inset-0 z-[95] flex items-end ${leaving ? 'sheet-leaving' : 'animate-fade-in'}`}>
<button
Expand Down Expand Up @@ -121,7 +125,10 @@ export default function ConfirmSheet({
{confirmLabel}
</span>
) : (
confirmLabel
<>
{confirmLabel}
<span aria-hidden="true" className="key-hint ml-2 text-[12px] opacity-60">↵</span>
</>
)}
</button>
</div>
Expand Down
11 changes: 10 additions & 1 deletion components/LogTimeSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ? <ButtonSpinner /> : 'Save'}
{saving ? (
<ButtonSpinner />
) : (
<>
Save
<span aria-hidden="true" className="key-hint ml-2 text-[12px] opacity-60">↵</span>
</>
)}
</button>
</div>
</section>
Expand Down
12 changes: 11 additions & 1 deletion components/SessionLogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
>
<HandCheck size={14} color="currentColor" />
{saving ? <span className="flex items-center justify-center gap-2"><ButtonSpinner />Saving…</span> : 'Save'}
{saving ? (
<span className="flex items-center justify-center gap-2"><ButtonSpinner />Saving…</span>
) : (
<>
Save
<span aria-hidden="true" className="key-hint text-[12px] opacity-60">↵</span>
</>
)}
</button>
</div>
</div>
Expand Down
53 changes: 37 additions & 16 deletions components/TimerHotkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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 (
<ConfirmSheet
open={confirmingReset && !!active}
title="Reset this sitting?"
body="The time so far won't be saved."
confirmLabel="Reset"
cancelLabel="Keep going"
onCancel={() => setConfirmingReset(false)}
onConfirm={() => {
setConfirmingReset(false);
cancel();
}}
/>
<>
<ConfirmSheet
open={confirmingReset && !!active}
title="Start this sitting over?"
body="The time so far won't be saved. The clock starts again from zero on the same task."
confirmLabel="Reset"
cancelLabel="Keep going"
onCancel={() => 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);
}}
/>
<ConfirmSheet
open={confirmingFinish && !!active}
title="Finish this sitting?"
body="The clock stops and you'll log it next."
confirmLabel="Finish"
cancelLabel="Keep going"
onCancel={() => setConfirmingFinish(false)}
onConfirm={() => {
setConfirmingFinish(false);
stop();
if (!isLogSheetMounted()) router.push('/timer');
}}
/>
</>
);
}
34 changes: 34 additions & 0 deletions lib/use-enter-key.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
22 changes: 17 additions & 5 deletions readmedesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading