From 89df140c00eb20e92d607e1338e4b03f0d4ec1f8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 01:49:53 +0200 Subject: [PATCH] feat: let readers record where they are and how much work it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 58 +++++++++---- src/client/components/EventDetail.tsx | 42 ++++++++++ src/client/components/EventRow.tsx | 41 ++++++++++ src/client/components/ProgressControls.tsx | 95 ++++++++++++++++++++++ 4 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 src/client/components/ProgressControls.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index ddc6ceb..64f7550 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -11,6 +11,7 @@ import { Legend } from "./components/Legend.tsx"; import { Toast } from "./components/Toast.tsx"; import { KEYS } from "./state/storage.ts"; import { useMarkSet } from "./state/useMarkSet.ts"; +import { useProgress } from "./state/useProgress.ts"; import { usePrefs } from "./state/usePrefs.ts"; import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; import type { GameId } from "../shared/schema.ts"; @@ -59,16 +60,18 @@ export function App() { const now = useNow(); const online = useOnline(); const { prefs, update, toggleGame } = usePrefs(); - const completed = useMarkSet(KEYS.completions); 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 wasIgnored = ignored.marks[id] !== undefined; ignored.toggle(id); setLastIgnored(wasIgnored ? null : { id, title }); }; - const completions = completed.marks; - const toggle = completed.toggle; + const toggle = prog.cycleStatus; useEffect(() => { const ac = new AbortController(); @@ -107,9 +110,9 @@ export function App() { // Ignored events are gone from both views unless deliberately revealed // — that is the whole point of ignoring one. .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), - [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); @@ -221,7 +224,9 @@ export function App() { ignored.toggle(id)} @@ -237,7 +242,9 @@ export function App() { ignored.toggle(id)} @@ -259,7 +266,7 @@ export function App() { rows={visible} now={now} onOpen={setOpenId} - completions={completions} + completions={prog.progress} /> )} @@ -269,10 +276,8 @@ export function App() { onToggleGame={toggleGame} onUpdate={update} ignoredCount={Object.keys(ignored.marks).length} - onExport={() => exportProgress(completions, ignored.marks, prefs)} - onImport={(file) => - void importProgress(file, completed.merge, ignored.merge) - } + onExport={() => exportProgress(prog.progress, ignored.marks, prefs)} + onImport={(file) => void importProgress(file, prog.merge, ignored.merge)} /> {!online && ( @@ -302,8 +307,14 @@ export function App() { {openRow !== null && ( toggleIgnored(id, openRow.event.title)} onToggle={toggle} onClose={() => setOpenId(null)} @@ -345,7 +356,7 @@ function Section({ } function exportProgress( - completions: Record, + progress: Record, ignored: Record, prefs: unknown, ) { @@ -356,7 +367,7 @@ function exportProgress( format: "gacha-tracker-export", version: 1, exportedAt: new Date().toISOString(), - completions, + progress, ignored, prefs, }, @@ -376,13 +387,14 @@ function exportProgress( async function importProgress( file: File, - mergeCompleted: (c: Record) => void, + mergeProgress: (c: Record) => void, mergeIgnored: (c: Record) => void, ) { try { const parsed: unknown = JSON.parse(await file.text()); const data = parsed as { format?: string; + progress?: unknown; completions?: unknown; ignored?: unknown; }; @@ -394,9 +406,19 @@ async function importProgress( typeof v === "object" && v !== null ? (v as Record) : 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); - 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); } catch { alert("That file couldn't be read. Export a fresh copy and try again."); diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 9004cdb..fe9e45b 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -2,26 +2,42 @@ import { useEffect } from "react"; import { gameMeta } from "../../shared/games.ts"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; 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"; export function EventDetail({ row, completed, ignored, + status, + effort, + note, onToggle, onIgnore, + onStatus, + onEffort, + onNote, onClose, }: { row: RowEvent; completed: boolean; ignored: boolean; + status: Status | undefined; + effort: Effort | undefined; + note: string; onToggle: (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; }) { const { event, clock } = row; const game = gameMeta(event.game); const heat = URGENCY_COLOR[clock.urgency]; + const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -85,6 +101,32 @@ export function EventDetail({ {event.type} + {risk !== "fine" && effort !== undefined && clock.msRemaining !== null && ( +

+ {pressureReason(effort, clock.msRemaining)} It is a rough guide, not a + verdict — you know your own schedule. +

+ )} + + onStatus(event.id, s)} + onEffort={(e) => onEffort(event.id, e)} + onNote={(n) => onNote(event.id, n)} + /> + {event.endPrecision === "day" && event.endsAt !== null && (

The source gave a date but no time of day, so this end is accurate to diff --git a/src/client/components/EventRow.tsx b/src/client/components/EventRow.tsx index 854ee62..758e201 100644 --- a/src/client/components/EventRow.tsx +++ b/src/client/components/EventRow.tsx @@ -5,6 +5,8 @@ import { windowCaption, type EventClock, } 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"; export interface RowEvent { @@ -15,6 +17,8 @@ export interface RowEvent { interface EventRowProps { row: RowEvent; completed: boolean; + status?: Status | undefined; + effort?: Effort | undefined; /** Only ever true when the reader has chosen to reveal ignored events. */ ignored?: boolean | undefined; onToggle: (id: string) => void; @@ -25,6 +29,8 @@ interface EventRowProps { export function EventRow({ row, completed, + status, + effort, ignored = false, onToggle, onRestore, @@ -35,6 +41,9 @@ export function EventRow({ const heat = URGENCY_COLOR[clock.urgency]; 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 ? `starts in ${formatRemaining(clock.startsMs - Date.now())}` @@ -95,6 +104,38 @@ export function EventRow({ + {(status === "doing" || effort !== undefined || risk !== "fine") && ( +

+ {status === "doing" && ( + + doing + + )} + {effort !== undefined && ( + + {EFFORT[effort].label.toLowerCase()} + + )} + {risk !== "fine" && ( + + {risk === "unlikely" ? "running out of time" : "tight"} + + )} +
+ )} + {event.summary !== null && (

{event.summary} diff --git a/src/client/components/ProgressControls.tsx b/src/client/components/ProgressControls.tsx new file mode 100644 index 0000000..6d494d4 --- /dev/null +++ b/src/client/components/ProgressControls.tsx @@ -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 ( +

+

Where are you with it?

+
+ {STATUSES.map((s) => { + const on = status === s.id; + return ( + + ); + })} +
+ +

How much work is it?

+
+ {EFFORT_LIST.map((e) => { + const on = effort === e.id; + return ( + + ); + })} +
+ + +