feat: let readers record where they are and how much work it is

The done checkbox now cycles untouched → doing → done, in the order people
actually move through. Effort, status and a note are set from the detail
sheet.

Rows carry chips for what the reader said, and a "tight" or "running out of
time" flag when the remaining time no longer covers the effort they recorded.
The detail sheet shows the arithmetic behind that rather than just asserting
it, and calls it a rough guide.

Imports from before this change are mapped forward rather than dropped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:49:53 +02:00
co-authored by Claude Opus 5
parent 2f7e5bdf79
commit 89df140c00
4 changed files with 218 additions and 18 deletions
+40 -18
View File
@@ -11,6 +11,7 @@ import { Legend } from "./components/Legend.tsx";
import { Toast } from "./components/Toast.tsx"; import { Toast } from "./components/Toast.tsx";
import { KEYS } from "./state/storage.ts"; import { KEYS } from "./state/storage.ts";
import { useMarkSet } from "./state/useMarkSet.ts"; import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts";
import { usePrefs } from "./state/usePrefs.ts"; import { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
import type { GameId } from "../shared/schema.ts"; import type { GameId } from "../shared/schema.ts";
@@ -59,16 +60,18 @@ export function App() {
const now = useNow(); const now = useNow();
const online = useOnline(); const online = useOnline();
const { prefs, update, toggleGame } = usePrefs(); const { prefs, update, toggleGame } = usePrefs();
const completed = useMarkSet(KEYS.completions);
const ignored = useMarkSet(KEYS.ignored); const ignored = useMarkSet(KEYS.ignored);
const prog = useProgress();
// "Completed" is now one status among several; the rest of the UI still asks
// this question a lot, so keep a cheap shorthand.
const isDone = (id: string) => prog.progress[id]?.status === "done";
const toggleIgnored = (id: string, title: string) => { const toggleIgnored = (id: string, title: string) => {
const wasIgnored = ignored.marks[id] !== undefined; const wasIgnored = ignored.marks[id] !== undefined;
ignored.toggle(id); ignored.toggle(id);
setLastIgnored(wasIgnored ? null : { id, title }); setLastIgnored(wasIgnored ? null : { id, title });
}; };
const completions = completed.marks; const toggle = prog.cycleStatus;
const toggle = completed.toggle;
useEffect(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
@@ -107,9 +110,9 @@ export function App() {
// Ignored events are gone from both views unless deliberately revealed // Ignored events are gone from both views unless deliberately revealed
// — that is the whole point of ignoring one. // — that is the whole point of ignoring one.
.filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined) .filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined)
.filter((r) => prefs.showCompleted || completions[r.event.id] === undefined) .filter((r) => prefs.showCompleted || !isDone(r.event.id))
.sort(endingSoonestFirst), .sort(endingSoonestFirst),
[allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, completions, ignored.marks], [allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks],
); );
const live = visible.filter((r) => r.clock.live); const live = visible.filter((r) => r.clock.live);
@@ -221,7 +224,9 @@ export function App() {
<EventRow <EventRow
key={row.event.id} key={row.event.id}
row={row} row={row}
completed={completions[row.event.id] !== undefined} completed={isDone(row.event.id)}
status={prog.progress[row.event.id]?.status}
effort={prog.progress[row.event.id]?.effort}
ignored={ignored.marks[row.event.id] !== undefined} ignored={ignored.marks[row.event.id] !== undefined}
onToggle={toggle} onToggle={toggle}
onRestore={(id) => ignored.toggle(id)} onRestore={(id) => ignored.toggle(id)}
@@ -237,7 +242,9 @@ export function App() {
<EventRow <EventRow
key={row.event.id} key={row.event.id}
row={row} row={row}
completed={completions[row.event.id] !== undefined} completed={isDone(row.event.id)}
status={prog.progress[row.event.id]?.status}
effort={prog.progress[row.event.id]?.effort}
ignored={ignored.marks[row.event.id] !== undefined} ignored={ignored.marks[row.event.id] !== undefined}
onToggle={toggle} onToggle={toggle}
onRestore={(id) => ignored.toggle(id)} onRestore={(id) => ignored.toggle(id)}
@@ -259,7 +266,7 @@ export function App() {
rows={visible} rows={visible}
now={now} now={now}
onOpen={setOpenId} onOpen={setOpenId}
completions={completions} completions={prog.progress}
/> />
)} )}
@@ -269,10 +276,8 @@ export function App() {
onToggleGame={toggleGame} onToggleGame={toggleGame}
onUpdate={update} onUpdate={update}
ignoredCount={Object.keys(ignored.marks).length} ignoredCount={Object.keys(ignored.marks).length}
onExport={() => exportProgress(completions, ignored.marks, prefs)} onExport={() => exportProgress(prog.progress, ignored.marks, prefs)}
onImport={(file) => onImport={(file) => void importProgress(file, prog.merge, ignored.merge)}
void importProgress(file, completed.merge, ignored.merge)
}
/> />
{!online && ( {!online && (
@@ -302,8 +307,14 @@ export function App() {
{openRow !== null && ( {openRow !== null && (
<EventDetail <EventDetail
row={openRow} row={openRow}
completed={completions[openRow.event.id] !== undefined} completed={isDone(openRow.event.id)}
ignored={ignored.marks[openRow.event.id] !== undefined} ignored={ignored.marks[openRow.event.id] !== undefined}
status={prog.progress[openRow.event.id]?.status}
effort={prog.progress[openRow.event.id]?.effort}
note={prog.progress[openRow.event.id]?.note ?? ""}
onStatus={prog.setStatus}
onEffort={prog.setEffort}
onNote={prog.setNote}
onIgnore={(id) => toggleIgnored(id, openRow.event.title)} onIgnore={(id) => toggleIgnored(id, openRow.event.title)}
onToggle={toggle} onToggle={toggle}
onClose={() => setOpenId(null)} onClose={() => setOpenId(null)}
@@ -345,7 +356,7 @@ function Section({
} }
function exportProgress( function exportProgress(
completions: Record<string, { at: string }>, progress: Record<string, unknown>,
ignored: Record<string, { at: string }>, ignored: Record<string, { at: string }>,
prefs: unknown, prefs: unknown,
) { ) {
@@ -356,7 +367,7 @@ function exportProgress(
format: "gacha-tracker-export", format: "gacha-tracker-export",
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
completions, progress,
ignored, ignored,
prefs, prefs,
}, },
@@ -376,13 +387,14 @@ function exportProgress(
async function importProgress( async function importProgress(
file: File, file: File,
mergeCompleted: (c: Record<string, { at: string }>) => void, mergeProgress: (c: Record<string, { at: string }>) => void,
mergeIgnored: (c: Record<string, { at: string }>) => void, mergeIgnored: (c: Record<string, { at: string }>) => void,
) { ) {
try { try {
const parsed: unknown = JSON.parse(await file.text()); const parsed: unknown = JSON.parse(await file.text());
const data = parsed as { const data = parsed as {
format?: string; format?: string;
progress?: unknown;
completions?: unknown; completions?: unknown;
ignored?: unknown; ignored?: unknown;
}; };
@@ -394,9 +406,19 @@ async function importProgress(
typeof v === "object" && v !== null typeof v === "object" && v !== null
? (v as Record<string, { at: string }>) ? (v as Record<string, { at: string }>)
: null; : null;
const c = asMarks(data.completions); // Accept exports from before progress replaced completions: membership
// there meant "done", so map it forward rather than dropping it.
const p = asMarks(data.progress);
const legacy = asMarks(data.completions);
const i = asMarks(data.ignored); const i = asMarks(data.ignored);
if (c !== null) mergeCompleted(c); if (p !== null) mergeProgress(p);
else if (legacy !== null) {
mergeProgress(
Object.fromEntries(
Object.entries(legacy).map(([id, m]) => [id, { ...m, status: "done" }]),
),
);
}
if (i !== null) mergeIgnored(i); if (i !== null) mergeIgnored(i);
} catch { } catch {
alert("That file couldn't be read. Export a fresh copy and try again."); alert("That file couldn't be read. Export a fresh copy and try again.");
+42
View File
@@ -2,26 +2,42 @@ import { useEffect } from "react";
import { gameMeta } from "../../shared/games.ts"; import { gameMeta } from "../../shared/games.ts";
import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx"; import type { RowEvent } from "./EventRow.tsx";
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
import type { Status } from "../state/useProgress.ts";
import { ProgressControls } from "./ProgressControls.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx"; import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export function EventDetail({ export function EventDetail({
row, row,
completed, completed,
ignored, ignored,
status,
effort,
note,
onToggle, onToggle,
onIgnore, onIgnore,
onStatus,
onEffort,
onNote,
onClose, onClose,
}: { }: {
row: RowEvent; row: RowEvent;
completed: boolean; completed: boolean;
ignored: boolean; ignored: boolean;
status: Status | undefined;
effort: Effort | undefined;
note: string;
onToggle: (id: string) => void; onToggle: (id: string) => void;
onIgnore: (id: string) => void; onIgnore: (id: string) => void;
onStatus: (id: string, s: Status | undefined) => void;
onEffort: (id: string, e: Effort | undefined) => void;
onNote: (id: string, n: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { event, clock } = row; const { event, clock } = row;
const game = gameMeta(event.game); const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -85,6 +101,32 @@ export function EventDetail({
<Field label="Type">{event.type}</Field> <Field label="Type">{event.type}</Field>
</dl> </dl>
{risk !== "fine" && effort !== undefined && clock.msRemaining !== null && (
<p
className="mt-4 rounded-lg border px-3 py-2 text-xs leading-relaxed"
style={{
borderColor:
risk === "unlikely"
? "color-mix(in srgb, var(--color-critical) 40%, transparent)"
: "color-mix(in srgb, var(--color-soon) 40%, transparent)",
color:
risk === "unlikely" ? "var(--color-critical)" : "var(--color-soon)",
}}
>
{pressureReason(effort, clock.msRemaining)} It is a rough guide, not a
verdict you know your own schedule.
</p>
)}
<ProgressControls
status={status}
effort={effort}
note={note}
onStatus={(s) => onStatus(event.id, s)}
onEffort={(e) => onEffort(event.id, e)}
onNote={(n) => onNote(event.id, n)}
/>
{event.endPrecision === "day" && event.endsAt !== null && ( {event.endPrecision === "day" && event.endsAt !== null && (
<p className="mt-3 text-xs leading-relaxed text-faint"> <p className="mt-3 text-xs leading-relaxed text-faint">
The source gave a date but no time of day, so this end is accurate to The source gave a date but no time of day, so this end is accurate to
+41
View File
@@ -5,6 +5,8 @@ import {
windowCaption, windowCaption,
type EventClock, type EventClock,
} from "../../shared/time.ts"; } from "../../shared/time.ts";
import { EFFORT, pressure, type Effort } from "../../shared/effort.ts";
import type { Status } from "../state/useProgress.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx"; import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent { export interface RowEvent {
@@ -15,6 +17,8 @@ export interface RowEvent {
interface EventRowProps { interface EventRowProps {
row: RowEvent; row: RowEvent;
completed: boolean; completed: boolean;
status?: Status | undefined;
effort?: Effort | undefined;
/** Only ever true when the reader has chosen to reveal ignored events. */ /** Only ever true when the reader has chosen to reveal ignored events. */
ignored?: boolean | undefined; ignored?: boolean | undefined;
onToggle: (id: string) => void; onToggle: (id: string) => void;
@@ -25,6 +29,8 @@ interface EventRowProps {
export function EventRow({ export function EventRow({
row, row,
completed, completed,
status,
effort,
ignored = false, ignored = false,
onToggle, onToggle,
onRestore, onRestore,
@@ -35,6 +41,9 @@ export function EventRow({
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
const caption = windowCaption(clock, Date.now()); const caption = windowCaption(clock, Date.now());
// Only ever a warning when the reader gave an estimate — inferring one to
// justify the warning would be inventing their input.
const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining);
const countdown = clock.upcoming const countdown = clock.upcoming
? `starts in ${formatRemaining(clock.startsMs - Date.now())}` ? `starts in ${formatRemaining(clock.startsMs - Date.now())}`
@@ -95,6 +104,38 @@ export function EventRow({
</span> </span>
</div> </div>
{(status === "doing" || effort !== undefined || risk !== "fine") && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{status === "doing" && (
<span className="rounded-[3px] bg-near/15 px-1.5 py-px text-[0.625rem] font-medium text-near">
doing
</span>
)}
{effort !== undefined && (
<span className="rounded-[3px] bg-hairline px-1.5 py-px text-[0.625rem] text-muted">
{EFFORT[effort].label.toLowerCase()}
</span>
)}
{risk !== "fine" && (
<span
className="rounded-[3px] px-1.5 py-px text-[0.625rem] font-medium"
style={{
background:
risk === "unlikely"
? "color-mix(in srgb, var(--color-critical) 18%, transparent)"
: "color-mix(in srgb, var(--color-soon) 18%, transparent)",
color:
risk === "unlikely"
? "var(--color-critical)"
: "var(--color-soon)",
}}
>
{risk === "unlikely" ? "running out of time" : "tight"}
</span>
)}
</div>
)}
{event.summary !== null && ( {event.summary !== null && (
<p className="row-summary mt-1 line-clamp-2 text-[0.8125rem] leading-snug text-muted"> <p className="row-summary mt-1 line-clamp-2 text-[0.8125rem] leading-snug text-muted">
{event.summary} {event.summary}
@@ -0,0 +1,95 @@
import { EFFORT_LIST, type Effort } from "../../shared/effort.ts";
import type { Status } from "../state/useProgress.ts";
const STATUSES: Array<{ id: Status | undefined; label: string }> = [
{ id: undefined, label: "Not started" },
{ id: "doing", label: "Doing it" },
{ id: "done", label: "Done" },
];
/**
* Where the reader is with an event, and how much work they reckon it is.
*
* Both are optional and both are theirs — nothing is inferred on their behalf.
* An event with no effort recorded never gets a "you won't finish this"
* warning, because we would be inventing the estimate the warning rests on.
*/
export function ProgressControls({
status,
effort,
note,
onStatus,
onEffort,
onNote,
}: {
status: Status | undefined;
effort: Effort | undefined;
note: string;
onStatus: (s: Status | undefined) => void;
onEffort: (e: Effort | undefined) => void;
onNote: (n: string) => void;
}) {
return (
<div className="mt-5 border-t border-hairline pt-4">
<p className="eyebrow">Where are you with it?</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{STATUSES.map((s) => {
const on = status === s.id;
return (
<button
key={s.label}
type="button"
onClick={() => onStatus(s.id)}
aria-pressed={on}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors duration-150 ${
on
? "border-near/70 bg-near/15 text-near"
: "border-hairline text-faint hover:text-muted"
}`}
>
{s.label}
</button>
);
})}
</div>
<p className="eyebrow mt-4">How much work is it?</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{EFFORT_LIST.map((e) => {
const on = effort === e.id;
return (
<button
key={e.id}
type="button"
// Tapping the current value clears it — the way back from a
// wrong guess should not need a separate control.
onClick={() => onEffort(on ? undefined : e.id)}
aria-pressed={on}
title={e.hint}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors duration-150 ${
on
? "border-soon/70 bg-soon/15 text-soon"
: "border-hairline text-faint hover:text-muted"
}`}
>
{e.label}
<span className="ml-1.5 text-[0.6875rem] opacity-70">{e.hint}</span>
</button>
);
})}
</div>
<label className="eyebrow mt-4 block" htmlFor="event-note">
Your notes
</label>
<textarea
id="event-note"
defaultValue={note}
onBlur={(e) => onNote(e.target.value)}
rows={2}
placeholder="Anything worth remembering — where you got to, what's left."
className="mt-2 w-full resize-y rounded-lg border border-hairline bg-ground px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-faint focus:outline-none"
/>
</div>
);
}