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 ( + + ); + })} +
+ + +