From 2f7e5bdf793c4107759e1a97db63570e058d45a0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 01:49:53 +0200 Subject: [PATCH] feat: replace the completions store with per-event progress Membership in a set can only say "done", so "started" was inexpressible. Progress carries a status instead, plus an optional effort estimate and a note. The old completions key is read once to seed status: "done", and is never written to or deleted. Someone who last opened the app six months ago still has their marks under it, these live only in the browser, and nothing else holds a copy to restore from. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/state/storage.ts | 6 ++ src/client/state/useProgress.ts | 129 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/client/state/useProgress.ts diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts index 07e324e..df02847 100644 --- a/src/client/state/storage.ts +++ b/src/client/state/storage.ts @@ -11,7 +11,13 @@ const NS = "gacha-tracker:v1"; export const KEYS = { + /** + * Superseded by `progress`, which carries a status rather than using + * membership to mean "done". Read once to migrate; never written, never + * deleted — see useProgress. + */ completions: `${NS}:completions`, + progress: `${NS}:progress`, ignored: `${NS}:ignored`, prefs: `${NS}:prefs`, } as const; diff --git a/src/client/state/useProgress.ts b/src/client/state/useProgress.ts new file mode 100644 index 0000000..d6fe974 --- /dev/null +++ b/src/client/state/useProgress.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from "react"; +import type { Effort } from "../../shared/effort.ts"; +import { KEYS, readJson, writeJson } from "./storage.ts"; +import type { Marks } from "./useMarkSet.ts"; + +/** Where the reader is with an event. Absent means untouched. */ +export type Status = "doing" | "done"; + +export interface Progress { + status?: Status | undefined; + effort?: Effort | undefined; + /** Anything the reader wants to remember about it. */ + note?: string | undefined; + at: string; +} + +export type ProgressMap = Record; + +/** + * Per-event notes the reader adds: where they are with it, how much work they + * think it is, and anything else worth remembering. + * + * Supersedes the completions-only store. Completions were `{ [id]: { at } }` + * with membership meaning "done"; this carries a status instead, so "started" + * is expressible. + * + * MIGRATION: the old completions store is read once to seed `status: "done"`, + * and is **never written to or deleted**. Someone who last opened the app six + * months ago still has their marks under that key, and these live only in the + * browser — nothing else holds a copy to restore from. See + * docs/DATA-MODEL.md § Client-side storage. + */ +function load(): ProgressMap { + const stored = readJson(KEYS.progress, {}); + if (Object.keys(stored).length > 0) return stored; + + const legacy = readJson(KEYS.completions, {}); + const seeded: ProgressMap = {}; + for (const [id, mark] of Object.entries(legacy)) { + seeded[id] = { status: "done", at: mark.at }; + } + return seeded; +} + +export function useProgress() { + const [progress, setProgress] = useState(load); + + useEffect(() => { + writeJson(KEYS.progress, progress); + }, [progress]); + + const patch = useCallback((id: string, next: Partial) => { + setProgress((prev) => { + const merged: Progress = { + ...prev[id], + ...next, + at: new Date().toISOString(), + }; + // An entry with nothing recorded is not worth keeping; drop it so the + // store stays a set of things the reader actually said something about. + if ( + merged.status === undefined && + merged.effort === undefined && + (merged.note ?? "") === "" + ) { + const { [id]: _removed, ...rest } = prev; + return rest; + } + return { ...prev, [id]: merged }; + }); + }, []); + + const setStatus = useCallback( + (id: string, status: Status | undefined) => patch(id, { status }), + [patch], + ); + + const cycleStatus = useCallback( + (id: string) => { + setProgress((prev) => { + // Untouched → doing → done → untouched. One control, three states, in + // the order the reader actually moves through them. + const current = prev[id]?.status; + const next: Status | undefined = + current === undefined ? "doing" : current === "doing" ? "done" : undefined; + const merged: Progress = { + ...prev[id], + status: next, + at: new Date().toISOString(), + }; + if ( + merged.status === undefined && + merged.effort === undefined && + (merged.note ?? "") === "" + ) { + const { [id]: _removed, ...rest } = prev; + return rest; + } + return { ...prev, [id]: merged }; + }); + }, + [], + ); + + const setEffort = useCallback( + (id: string, effort: Effort | undefined) => patch(id, { effort }), + [patch], + ); + + const setNote = useCallback( + (id: string, note: string) => patch(id, { note: note.trim() }), + [patch], + ); + + /** Union merge on import, keeping the earlier entry. Never removes. */ + const merge = useCallback((incoming: ProgressMap) => { + setProgress((prev) => { + const next = { ...prev }; + for (const [id, value] of Object.entries(incoming)) { + const existing = next[id]; + next[id] = + existing === undefined || value.at < existing.at ? value : existing; + } + return next; + }); + }, []); + + return { progress, patch, setStatus, cycleStatus, setEffort, setNote, merge }; +}