diff --git a/src/client/App.tsx b/src/client/App.tsx index dba30a5..db75193 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -16,6 +16,7 @@ import { useMarkSet } from "./state/useMarkSet.ts"; import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { usePrefs } from "./state/usePrefs.ts"; +import { useCustom } from "./state/useCustom.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { advanceFocus, @@ -26,8 +27,14 @@ import { } from "./state/lens.ts"; import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; -import { useGameMeta } from "./state/gameMeta.tsx"; -import type { LaneId } from "../shared/custom.ts"; +import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; +import { + isCustomGameId, + type CustomEvents, + type CustomGames, + type LaneId, +} from "../shared/custom.ts"; +import { metaFor } from "../shared/games.ts"; type View = "soon" | "calendar"; @@ -70,13 +77,24 @@ export function App() { // The event most recently ignored, so it can be put back without hunting for // a row that just disappeared. const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null); - const gameMeta = useGameMeta(); const now = useNow(); const online = useOnline(); const { prefs, update, toggleGame } = usePrefs(); const ignored = useMarkSet(KEYS.ignored); const prog = useProgress(); const daily = useDailyLog(); + const custom = useCustom(); + /** + * How every lane in this tree is named and coloured. + * + * App owns it because App is the only thing holding the reader's own games, + * and hands it down rather than letting components import a lookup that can + * only ever answer for the tracked ones. + */ + const gameMeta = useMemo( + () => (id) => metaFor(id, custom.games), + [custom.games], + ); // "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"; @@ -144,17 +162,25 @@ export function App() { const allRows = useMemo(() => { if (state.status !== "ready") return []; - return state.feed.events - .filter((e) => e.status === "published") - .map((event) => ({ event, clock: clockFor(event, prefs.region, now) })); + // The reader's own events are events. They sort, filter, focus, expire and + // tick exactly like scraped ones — what sets them apart is only that + // nothing is claimed about where their dates came from. + return [ + ...state.feed.events.filter((e) => e.status === "published"), + ...custom.rows, + ].map((event) => ({ event, clock: clockFor(event, prefs.region, now) })); // `now` intentionally excluded: recomputing every clock each second is // wasteful, and the countdown text re-renders from `now` anyway. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state, prefs.region, Math.floor(now / 60_000)]); + }, [state, custom.rows, prefs.region, Math.floor(now / 60_000)]); + // Feed lanes come from rows, the reader's from the games themselves — so a + // game they just created shows up in the filters before it holds anything. + // It is still not a scraped game with an empty feed: it has no source row, no + // freshness badge and no colophon credit. const games = useMemo( - () => [...new Set(allRows.map((r) => r.event.game))], - [allRows], + () => [...new Set([...allRows.map((r) => r.event.game), ...custom.lanes])], + [allRows, custom.lanes], ); /** Games the reader plays, in feed order. The focus bar rotates through these. */ @@ -251,17 +277,19 @@ export function App() { // up by default rather than staying invisible. if (!prefs.onboarded) { return ( - - - update({ - onboarded: true, - hiddenGames: games.filter((g) => !chosen.includes(g)), - }) - } - /> - + + + + update({ + onboarded: true, + hiddenGames: games.filter((g) => !chosen.includes(g)), + }) + } + /> + + ); } @@ -270,6 +298,7 @@ export function App() { ); return ( +
@@ -336,8 +365,13 @@ export function App() { {/* The chores no wiki publishes, and the only thing on this page that expires tonight rather than next patch. */} + {/* Standing chores are a tracked-game notion: there is no routine we + could name on behalf of a game the reader invented, so their lanes + contribute repeating events here but no chore of their own. */} !isCustomGameId(id), + )} events={todo.filter(repeatsDaily).map((r) => r.event)} region={prefs.region} now={now} @@ -430,10 +464,19 @@ export function App() { onUpdate={update} ignoredCount={Object.keys(ignored.marks).length} onExport={() => - exportProgress(prog.progress, daily.logs, ignored.marks, prefs) + exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { + games: custom.games, + events: custom.events, + }) } onImport={(file) => - void importProgress(file, prog.merge, daily.merge, ignored.merge) + void importProgress( + file, + prog.merge, + daily.merge, + ignored.merge, + custom.merge, + ) } /> @@ -484,6 +527,7 @@ export function App() { /> )} + ); } @@ -565,6 +609,7 @@ function exportProgress( daily: DailyLogMap, ignored: Record, prefs: unknown, + own: { games: CustomGames; events: CustomEvents }, ) { const blob = new Blob( [ @@ -578,6 +623,11 @@ function exportProgress( // an export that omitted them would quietly be a lossy backup. daily, ignored, + // The reader's own games and events exist nowhere else at all — not + // in the feed, not on a server. An export without them is a backup + // that quietly loses the half they typed themselves. + customGames: own.games, + customEvents: own.events, prefs, }, null, @@ -599,6 +649,7 @@ async function importProgress( mergeProgress: (c: Record) => void, mergeDaily: (c: DailyLogMap) => void, mergeIgnored: (c: Record) => void, + mergeCustom: (games: unknown, events: unknown) => void, ) { try { const parsed: unknown = JSON.parse(await file.text()); @@ -608,6 +659,8 @@ async function importProgress( completions?: unknown; daily?: unknown; ignored?: unknown; + customGames?: unknown; + customEvents?: unknown; }; if (data.format !== "gacha-tracker-export") { alert("That file isn't an Event Clock export."); @@ -635,6 +688,11 @@ async function importProgress( const d = data.daily; if (typeof d === "object" && d !== null) mergeDaily(d as DailyLogMap); if (i !== null) mergeIgnored(i); + // Additive keys: an export written before F13 has neither, which is a file + // from a device that had none rather than an error. An event and the game + // it belongs to always travel together, so this can never land a lane with + // nothing to name it. + mergeCustom(data.customGames, data.customEvents); } catch { alert("That file couldn't be read. Export a fresh copy and try again."); } diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts index 81a56f5..4839c8d 100644 --- a/src/client/state/storage.ts +++ b/src/client/state/storage.ts @@ -25,6 +25,16 @@ export const KEYS = { daily: `${NS}:daily`, ignored: `${NS}:ignored`, prefs: `${NS}:prefs`, + /** + * Games and events the reader entered themselves (PRD F13). + * + * Two keys rather than one because they have different lifetimes: a game + * outlives the events in it, and deleting one is refused while the other + * still references it. Like everything else here, this is the only copy — + * there is no server that has ever seen it. + */ + customGames: `${NS}:customGames`, + customEvents: `${NS}:customEvents`, } as const; /** diff --git a/src/client/state/useCustom.ts b/src/client/state/useCustom.ts new file mode 100644 index 0000000..5171a5f --- /dev/null +++ b/src/client/state/useCustom.ts @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + asDisplayEvent, + CustomEvent, + CustomGame, + mintCustomEventId, + mintCustomGameId, + precisionOf, + type CustomEvents, + type CustomGames, + type DisplayEvent, + type LaneId, +} from "../../shared/custom.ts"; +import type { EventType } from "../../shared/schema.ts"; +import { KEYS, readJson, writeJson } from "./storage.ts"; + +/** + * The reader's own games and events (PRD F13). + * + * Nothing here is fetched, parsed, merged or scored — this is the one part of + * the app whose data the reader typed, and the ingest pipeline has no business + * touching it. What it does share is everything downstream: the events are + * projected into `DisplayEvent` and join the same lists, timeline, sort, + * progress, ignore and daily stores as scraped ones. + */ + +/** What a form hands over. Instants are already resolved; precision is not. */ +export interface EventDraft { + game: LaneId; + title: string; + type: EventType; + summary: string | null; + startsAt: string; + startHasTime: boolean; + endsAt: string | null; + endHasTime: boolean; +} + +/** + * A date the reader typed, as a UTC instant. + * + * Read in **their** timezone, not UTC: someone who types 20 August means the + * 20th where they are, and must see the 20th back. A start with no time is the + * beginning of that day and an end with no time is the end of it, which is how + * a person reads "20 Aug – 3 Sep" — the feed's own day-precision boundaries sit + * at 00:00Z on both sides, but those are a parser declining to guess a time the + * source never printed, and this reader is telling us directly. + */ +export function readerInstant( + date: string, + time: string | null, + boundary: "start" | "end", +): string | null { + const wall = + time !== null && time !== "" + ? `${date}T${time}` + : `${date}T${boundary === "start" ? "00:00:00" : "23:59:59"}`; + const ms = Date.parse(wall); + if (Number.isNaN(ms)) return null; + + // `Date.parse` rolls an impossible date over rather than refusing it — 30 + // February becomes 2 March — and a silently shifted date is the one thing + // this codebase never ships. `dates.ts` guards its parsers the same way. + const [y, m, d] = date.split("-").map(Number); + const at = new Date(ms); + if (at.getFullYear() !== y || at.getMonth() + 1 !== m || at.getDate() !== d) { + return null; + } + return at.toISOString(); +} + +/** + * Read a store, keeping the records that still parse. + * + * A record that does not is dropped rather than taking the app down with it, + * and says so — the same trade `readJson` makes, one level deeper. Silence is + * the thing this codebase does not hand out for free. + */ +function readValid( + key: string, + schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, + label: string, +): Record { + const raw = readJson>(key, {}); + const out: Record = {}; + for (const [id, value] of Object.entries(raw)) { + const parsed = schema.safeParse(value); + if (parsed.success && parsed.data !== undefined) out[id] = parsed.data; + else console.warn(`dropped an unreadable ${label}: ${id}`); + } + return out; +} + +export function useCustom() { + const [games, setGames] = useState(() => + readValid(KEYS.customGames, CustomGame, "custom game"), + ); + const [events, setEvents] = useState(() => + readValid(KEYS.customEvents, CustomEvent, "custom event"), + ); + + useEffect(() => { + writeJson(KEYS.customGames, games); + }, [games]); + useEffect(() => { + writeJson(KEYS.customEvents, events); + }, [events]); + + const addGame = useCallback((name: string, hue: string): string => { + const id = mintCustomGameId(name, Object.keys(games)); + const game = CustomGame.parse({ + id, + name: name.trim(), + hue, + at: new Date().toISOString(), + }); + setGames((prev) => ({ ...prev, [id]: game })); + return id; + }, [games]); + + const editGame = useCallback((id: string, name: string, hue: string) => { + setGames((prev) => { + const existing = prev[id]; + if (existing === undefined) return prev; + // The id never follows the name — see docs/DATA-MODEL.md. Renaming a game + // must not move the lane its events point at. + return { ...prev, [id]: { ...existing, name: name.trim(), hue } }; + }); + }, []); + + /** + * Remove a game, if nothing of theirs still lives in it. + * + * Refused rather than cascading: deleting a lane should not quietly take a + * fortnight of events with it, and the count is more use than an undo. + */ + const removeGame = useCallback( + (id: string): { removed: boolean; blockedBy: number } => { + const holding = Object.values(events).filter((e) => e.game === id).length; + if (holding > 0) return { removed: false, blockedBy: holding }; + setGames((prev) => { + const { [id]: _gone, ...rest } = prev; + return rest; + }); + return { removed: true, blockedBy: 0 }; + }, + [events], + ); + + const addEvent = useCallback((draft: EventDraft): string => { + const now = new Date().toISOString(); + const event = CustomEvent.parse({ + id: mintCustomEventId(), + game: draft.game, + title: draft.title.trim(), + type: draft.type, + summary: draft.summary === null || draft.summary.trim() === "" + ? null + : draft.summary.trim(), + startsAt: draft.startsAt, + startPrecision: precisionOf(draft.startHasTime), + endsAt: draft.endsAt, + // An unannounced end is a supported answer here exactly as it is in the + // feed. Nobody is made to invent a date to satisfy a form. + endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime), + at: now, + updatedAt: now, + }); + setEvents((prev) => ({ ...prev, [event.id]: event })); + return event.id; + }, []); + + const editEvent = useCallback((id: string, draft: EventDraft) => { + setEvents((prev) => { + const existing = prev[id]; + if (existing === undefined) return prev; + const next = CustomEvent.parse({ + ...existing, + game: draft.game, + title: draft.title.trim(), + type: draft.type, + summary: draft.summary === null || draft.summary.trim() === "" + ? null + : draft.summary.trim(), + startsAt: draft.startsAt, + startPrecision: precisionOf(draft.startHasTime), + endsAt: draft.endsAt, + endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime), + updatedAt: new Date().toISOString(), + }); + return { ...prev, [id]: next }; + }); + }, []); + + /** + * Forget an event the reader entered. + * + * Their marks and logged days for it stay where they are. Reaching into three + * other stores on a single tap is how a misclick costs someone a streak, and + * an orphaned mark costs them nothing. + */ + const removeEvent = useCallback((id: string) => { + setEvents((prev) => { + const { [id]: _gone, ...rest } = prev; + return rest; + }); + }, []); + + /** Import: union by id, never removing what this device already has. */ + const merge = useCallback( + (incomingGames: unknown, incomingEvents: unknown) => { + const g = validated(incomingGames, CustomGame); + const e = validated(incomingEvents, CustomEvent); + if (Object.keys(g).length > 0) setGames((prev) => ({ ...g, ...prev })); + if (Object.keys(e).length > 0) setEvents((prev) => ({ ...e, ...prev })); + }, + [], + ); + + /** The reader's events, in the shape every view reads. */ + const rows = useMemo( + () => Object.values(events).map(asDisplayEvent), + [events], + ); + + /** Lanes the reader defined, so filters and focus can see them. */ + const lanes = useMemo(() => Object.keys(games), [games]); + + return { + games, + events, + rows, + lanes, + addGame, + editGame, + removeGame, + addEvent, + editEvent, + removeEvent, + merge, + }; +} + +function validated( + input: unknown, + schema: { safeParse: (v: unknown) => { success: boolean; data?: T } }, +): Record { + if (typeof input !== "object" || input === null) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(input as Record)) { + const parsed = schema.safeParse(value); + if (parsed.success && parsed.data !== undefined) out[id] = parsed.data; + } + return out; +} diff --git a/test/custom.test.ts b/test/custom.test.ts index a69b560..0cedf03 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -16,6 +16,7 @@ import { metaFor } from "../src/shared/games.ts"; import { dailiesId } from "../src/shared/daily.ts"; import { eventId, GameId } from "../src/shared/schema.ts"; import { clockFor } from "../src/shared/time.ts"; +import { readerInstant } from "../src/client/state/useCustom.ts"; const AT = "2026-08-17T12:00:00.000Z"; @@ -244,3 +245,42 @@ describe("knownLane", () => { expect(knownLane("mygame:gone", mine)).toBe(false); }); }); + +describe("readerInstant", () => { + // Timezone-independent assertions on purpose: the point of this helper is + // that it reads a typed date in the *reader's* zone, so the tests check the + // relationships that must hold in any of them rather than pinning UTC. + const localDate = (iso: string) => + new Date(iso).toLocaleDateString("en-CA"); // YYYY-MM-DD in local time + + test("a typed date comes back as that same date where the reader is", () => { + // Someone who types 20 August means the 20th where they are, and has to see + // the 20th back — not the 19th because a server is five hours behind. + for (const boundary of ["start", "end"] as const) { + const iso = readerInstant("2026-08-20", null, boundary); + expect(iso).not.toBeNull(); + expect(localDate(iso!)).toBe("2026-08-20"); + } + }); + + test("a bare start is the beginning of the day and a bare end is the end of it", () => { + // Which is how a person reads "20 Aug – 3 Sep": through the 3rd, not up to + // the first second of it. + const start = readerInstant("2026-08-20", null, "start")!; + const end = readerInstant("2026-08-20", null, "end")!; + expect(Date.parse(end) - Date.parse(start)).toBe(86_399_000); + }); + + test("a stated time is kept", () => { + const iso = readerInstant("2026-08-20", "18:30", "start")!; + const d = new Date(iso); + expect(d.getHours()).toBe(18); + expect(d.getMinutes()).toBe(30); + }); + + test("returns null for a date it cannot read, rather than a wrong one", () => { + expect(readerInstant("", null, "start")).toBeNull(); + expect(readerInstant("not-a-date", null, "start")).toBeNull(); + expect(readerInstant("2026-02-30", null, "start")).toBeNull(); + }); +});