From c87ea4c602ede2215a4aec77a67ac348c49e97c9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Mon, 17 Aug 2026 18:24:09 +0200 Subject: [PATCH] feat(custom): let readers add their own games and events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface for PRD F13. A game (name and lane colour) and events against it, or against a tracked game when a source missed one, managed from the settings panel and from the event's own detail sheet. Two things the forms are careful about: - "I don't know when it ends" is an offered answer, not a blank field. A mandatory end date would push the reader into inventing one, which is the failure the parsers are forbidden from committing — it would just be the reader committing it instead. An unknown end behaves as it does for a scraped event: no countdown, no checklist. - A hand-entered date is never dressed up as a source's. The row carries a "yours" chip, the sheet says so under the title, and the Source link is absent rather than dead. Deleting a game is refused while it still holds events, and says how many. Deleting an event leaves the marks and ticks attached to it alone. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 27 +- src/client/App.tsx | 22 ++ src/client/components/Controls.tsx | 10 +- src/client/components/CustomForms.tsx | 343 ++++++++++++++++++++++++++ src/client/components/EventDetail.tsx | 77 +++++- src/client/components/EventRow.tsx | 9 +- src/client/components/YourOwn.tsx | 162 ++++++++++++ test/custom-ui.test.tsx | 141 +++++++++++ 8 files changed, 786 insertions(+), 5 deletions(-) create mode 100644 src/client/components/CustomForms.tsx create mode 100644 src/client/components/YourOwn.tsx create mode 100644 test/custom-ui.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index e47c629..cd4c568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,15 +76,17 @@ re-verify a sample against the live page afterward. ``` src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts + custom.ts — reader-authored games and events, and their key spaces src/ingest/ html.ts, dates.ts (nine formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts parsers/ game8.ts, wikigg.ts, akwiki.ts — keyed by SITE, not game adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam src/client/ React app, service worker, manifest state/ progress, daily log, ignores, prefs, sort — all localStorage + useCustom.ts — the reader's own games and events (PRD F13) lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 366 tests +test/ 396 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` @@ -229,6 +231,29 @@ event's length. It adds **no schema field**, so the feed contract is untouched. recording agreement would freeze today's guess and stop a better parser from ever reaching that event. Neither control ever deletes a mark or a logged day, so both are reversible. +## Events the reader entered themselves + +No adapter list covers a ten-game player, so a reader can define a game and type in events +(PRD F13, `src/shared/custom.ts`, `src/client/state/useCustom.ts`). They join the same lists, +timeline, sort, filters, progress, ignore and daily stores as scraped events. Four rules: + +- **Their ids live in their own spaces**: `mygame:` and `myevent:`. Never + `${game}:${slug}:${date}` — a reader can type a scraped event's exact title and date, and that + collision would silently share one completion mark and one streak between two events. Random also + means renaming their own event never moves its id. `dailies`, `mygame` and `myevent` are reserved + first segments and **none may ever become a `GameId`**; a test pins this. +- **Nothing they type enters the ingest pipeline.** `sanitize.ts` and `merge.ts` are for pages we do + not control. Their events are not fetched, parsed, merged, scored or quarantined. +- **A hand-entered date is never attributed to a source.** No `sourceUrl`, no source link, and the + row and detail sheet both say it is theirs. `"I don't know when it ends"` is an offered answer, for + the same reason the parsers are forbidden from guessing one. +- **They are in the export.** These exist in one browser and nowhere else, so an export without them + is a lossy backup. Import merges by id and never removes. + +A lane may now be a game the reader invented, so `gameMeta` is a context resolver (`metaFor`, pure +and total) rather than a direct lookup — a lane can outlive its game when an import carries an event +whose game did not come with it. + ## Conventions - **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never diff --git a/src/client/App.tsx b/src/client/App.tsx index db75193..e43ae96 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -463,6 +463,17 @@ export function App() { onToggleGame={toggleGame} onUpdate={update} ignoredCount={Object.keys(ignored.marks).length} + own={{ + games: custom.games, + events: custom.events, + // Every lane, not just theirs: a source can miss an event in a game + // we do track, and that is the same job with the same form. + lanes: games, + onAddGame: custom.addGame, + onEditGame: custom.editGame, + onRemoveGame: custom.removeGame, + onAddEvent: custom.addEvent, + }} onExport={() => exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { games: custom.games, @@ -524,6 +535,17 @@ export function App() { onNote={prog.setNote} onIgnore={(id) => toggleIgnored(id, openRow.event.title)} onClose={() => setOpenId(null)} + own={ + custom.events[openRow.event.id] === undefined + ? undefined + : { + record: custom.events[openRow.event.id]!, + lanes: games, + games: custom.games, + onSave: custom.editEvent, + onDelete: custom.removeEvent, + } + } /> )} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 29a81bd..7c78bd5 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -2,6 +2,7 @@ import type { LaneId } from "../../shared/custom.ts"; import type { Region } from "../../shared/schema.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import type { Prefs } from "../state/usePrefs.ts"; +import { YourOwn } from "./YourOwn.tsx"; const REGIONS: Array<{ id: Region; label: string }> = [ { id: "america", label: "America" }, @@ -17,6 +18,7 @@ export function Controls({ ignoredCount, onExport, onImport, + own, }: { games: LaneId[]; prefs: Prefs; @@ -25,6 +27,8 @@ export function Controls({ ignoredCount: number; onExport: () => void; onImport: (file: File) => void; + /** Everything the reader entered themselves, and the ways to change it. */ + own: React.ComponentProps; }) { const gameMeta = useGameMeta(); return ( @@ -127,12 +131,14 @@ export function Controls({ + +

Your progress

What you've finished, and every daily you've ticked off, are saved in - this browser only — there is no account. Move them to another device - with a file. + this browser only — there is no account. Anything you added yourself is + in there too. Move it all to another device with a file.

+ +
+ + +
+ + ); +} + +/** Split a stored instant back into the date and time a form field wants. */ +function fields(iso: string | null): { date: string; time: string } { + if (iso === null) return { date: "", time: "" }; + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return { + date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }; +} + +export function EventForm({ + lanes, + customGames, + initial, + onSave, + onCancel, +}: { + /** Every lane an event can belong to — a source can miss an event too. */ + lanes: LaneId[]; + customGames: CustomGames; + initial?: CustomEvent | undefined; + onSave: (draft: EventDraft) => void; + onCancel: () => void; +}) { + const gameMeta = useGameMeta(); + const start = fields(initial?.startsAt ?? null); + const end = fields(initial?.endsAt ?? null); + + const [game, setGame] = useState( + initial?.game ?? lanes[0] ?? Object.keys(customGames)[0] ?? "", + ); + const [title, setTitle] = useState(initial?.title ?? ""); + const [type, setType] = useState(initial?.type ?? "other"); + const [summary, setSummary] = useState(initial?.summary ?? ""); + const [startDate, setStartDate] = useState(start.date); + const [startTime, setStartTime] = useState( + initial?.startPrecision === "exact" ? start.time : "", + ); + // Separate from an empty end date so "I don't know" is a thing the reader + // states, not a field they leave blank and hope about. + const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true); + const [endDate, setEndDate] = useState(end.date); + const [endTime, setEndTime] = useState( + initial?.endPrecision === "exact" ? end.time : "", + ); + + const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start"); + const endsAt = + !endKnown || endDate === "" ? null : readerInstant(endDate, endTime, "end"); + + const endMissing = endKnown && endDate !== "" && endsAt === null; + const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; + const valid = + title.trim().length > 0 && + game !== "" && + startsAt !== null && + !backwards && + !endMissing && + (!endKnown || endDate !== ""); + + return ( +
{ + e.preventDefault(); + if (!valid || startsAt === null) return; + onSave({ + game, + title, + type, + summary, + startsAt, + startHasTime: startTime !== "", + endsAt, + endHasTime: endTime !== "", + }); + }} + > + + + + + + +
+ + +
+ + {/* The end is allowed to be unknown, and says so out loud. Making it + mandatory would push the reader into inventing a date, which is + exactly the failure the parsers are forbidden from committing. */} + + + {endKnown && ( +
+ + +
+ )} + + + + {!endKnown && ( +

+ It'll show with no countdown and no daily checklist, the same as an + event whose source hasn't announced an end. +

+ )} + {backwards && ( +

+ That ends before it starts. +

+ )} + {endMissing && ( +

That end date isn't a real date.

+ )} + {startTime === "" && startDate !== "" && ( +

+ No time given, so this counts from the start of the day where you are — + and to the end of the day it finishes on. +

+ )} + +
+ + +
+
+ ); +} diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 0471279..38176c8 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -1,5 +1,13 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; +import { + isCustomEventId, + type CustomEvent, + type CustomGames, + type LaneId, +} from "../../shared/custom.ts"; +import type { EventDraft } from "../state/useCustom.ts"; +import { EventForm } from "./CustomForms.tsx"; import { formatAbsolute, formatRemaining } from "../../shared/time.ts"; import type { RowEvent } from "./EventRow.tsx"; import { pressure, pressureReason, type Effort } from "../../shared/effort.ts"; @@ -29,6 +37,7 @@ export function EventDetail({ onEffort, onNote, onClose, + own, }: { row: RowEvent; completed: boolean; @@ -51,8 +60,23 @@ export function EventDetail({ onEffort: (id: string, e: Effort | undefined) => void; onNote: (id: string, n: string) => void; onClose: () => void; + /** + * Present only when this is an event the reader entered themselves, in which + * case they can change it or take it back — this sheet is where anyone would + * look for that, rather than a list in settings. + */ + own?: + | { + record: CustomEvent; + lanes: LaneId[]; + games: CustomGames; + onSave: (id: string, draft: EventDraft) => void; + onDelete: (id: string) => void; + } + | undefined; }) { const gameMeta = useGameMeta(); + const [editing, setEditing] = useState(false); const { event, clock } = row; const game = gameMeta(event.game); const heat = URGENCY_COLOR[clock.urgency]; @@ -86,6 +110,14 @@ export function EventDetail({

{event.title}

+ {/* Stated plainly, next to the title rather than buried at the bottom: + a reader has to be able to tell which dates the app went and found + and which ones they typed in themselves. */} + {isCustomEventId(event.id) && ( +

+ You added this. The dates are yours, not a source's. +

+ )} {event.summary !== null && (

{event.summary}

)} @@ -190,6 +222,49 @@ export function EventDetail({ onNote={(n) => onNote(event.id, n)} /> + {own !== undefined && ( +
+ {editing ? ( + { + own.onSave(event.id, draft); + setEditing(false); + }} + onCancel={() => setEditing(false)} + /> + ) : ( +
+ + +
+ )} + {/* Their marks and logged days are not swept up with it: reaching + into three other stores on one tap is how a misclick costs + somebody a streak. */} +

+ Deleting removes the event. Anything you ticked off stays. +

+
+ )} + {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 649c707..dc3c022 100644 --- a/src/client/components/EventRow.tsx +++ b/src/client/components/EventRow.tsx @@ -1,4 +1,4 @@ -import type { DisplayEvent } from "../../shared/custom.ts"; +import { isCustomEventId, type DisplayEvent } from "../../shared/custom.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { formatRemaining, @@ -88,6 +88,13 @@ export function EventRow({

{game.short} + {/* A date the reader typed is never allowed to look like one + a source published. */} + {isCustomEventId(event.id) && ( + + yours + + )} {ignored && ( ignored diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx new file mode 100644 index 0000000..163fb8a --- /dev/null +++ b/src/client/components/YourOwn.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import type { CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts"; +import { useGameMeta } from "../state/gameMeta.tsx"; +import type { EventDraft } from "../state/useCustom.ts"; +import { EventForm, GameForm } from "./CustomForms.tsx"; + +/** + * The reader's own games and events, in the settings panel (PRD F13). + * + * Their events are managed from the event itself — open it and the detail sheet + * offers edit and delete, exactly where you would look for them. What has no + * other home is the list of games they invented, and the way in to adding the + * first event, so both live here. + */ +export function YourOwn({ + games, + events, + lanes, + onAddGame, + onEditGame, + onRemoveGame, + onAddEvent, +}: { + games: CustomGames; + events: CustomEvents; + /** Every lane an event may be filed under, tracked games included. */ + lanes: LaneId[]; + onAddGame: (name: string, hue: string) => void; + onEditGame: (id: string, name: string, hue: string) => void; + onRemoveGame: (id: string) => { removed: boolean; blockedBy: number }; + onAddEvent: (draft: EventDraft) => void; +}) { + const gameMeta = useGameMeta(); + const [adding, setAdding] = useState<"game" | "event" | null>(null); + const [editing, setEditing] = useState(null); + const [refusal, setRefusal] = useState(null); + + const list = Object.values(games); + + return ( +
+

Your own games and events

+

+ Track something this app doesn't cover, or an event a source missed. Your + dates are yours — they're never presented as coming from a wiki, and they + travel in your export. +

+ + {list.length > 0 && ( +
    + {list.map((game) => { + const held = Object.values(events).filter( + (e) => e.game === game.id, + ).length; + return ( +
  • +
    + + {game.name} + + {held === 0 + ? "no events yet" + : `${held} event${held > 1 ? "s" : ""}`} + + + +
    + + {editing === game.id && ( + { + onEditGame(game.id, name, hue); + setEditing(null); + }} + onCancel={() => setEditing(null)} + /> + )} +
  • + ); + })} +
+ )} + + {refusal !== null && ( +

{refusal}

+ )} + + {adding === "game" && ( + { + onAddGame(name, hue); + setAdding(null); + }} + onCancel={() => setAdding(null)} + /> + )} + + {adding === "event" && ( + { + onAddEvent(draft); + setAdding(null); + }} + onCancel={() => setAdding(null)} + /> + )} + + {adding === null && ( +
+ + +
+ )} +
+ ); +} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx new file mode 100644 index 0000000..8a25970 --- /dev/null +++ b/test/custom-ui.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { EventForm } from "../src/client/components/CustomForms.tsx"; +import { YourOwn } from "../src/client/components/YourOwn.tsx"; +import { EventRow } from "../src/client/components/EventRow.tsx"; +import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; +import { + asDisplayEvent, + CustomEvent, + type CustomEvents, + type CustomGames, +} from "../src/shared/custom.ts"; +import { metaFor } from "../src/shared/games.ts"; +import { clockFor } from "../src/shared/time.ts"; + +/** + * Static-render checks on the F13 surfaces. + * + * Not a substitute for using the thing, but they pin the two claims the feature + * makes to a reader: their event is marked as theirs, and the end date is + * allowed to be unknown. + */ + +const AT = "2026-08-17T12:00:00.000Z"; + +const GAMES: CustomGames = { + "mygame:limbus-company": { + id: "mygame:limbus-company", + name: "Limbus Company", + hue: "#C74B50", + at: AT, + }, +}; + +const OWN = CustomEvent.parse({ + id: "myevent:k3f9qa2m01", + game: "mygame:limbus-company", + title: "Walpurgisnacht", + type: "banner", + summary: null, + startsAt: "2026-08-20T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-09-03T00:00:00.000Z", + endPrecision: "day", + at: AT, + updatedAt: AT, +}); + +const EVENTS: CustomEvents = { [OWN.id]: OWN }; + +function render(node: React.ReactElement): string { + return renderToStaticMarkup( + metaFor(id, GAMES)}>{node}, + ); +} + +describe("YourOwn", () => { + const noop = () => {}; + const props = { + games: GAMES, + events: EVENTS, + lanes: ["genshin", "mygame:limbus-company"], + onAddGame: noop, + onEditGame: noop, + onRemoveGame: () => ({ removed: true, blockedBy: 0 }), + onAddEvent: noop, + }; + + test("lists a reader's game with the events it holds", () => { + const html = render(); + expect(html).toContain("Limbus Company"); + expect(html).toContain("1 event"); + expect(html).toContain("#C74B50"); + }); + + test("shows a game with nothing in it rather than hiding it", () => { + // A game they just made has to appear before it holds anything, or adding + // one looks like it did nothing. + const html = render(); + expect(html).toContain("no events yet"); + }); +}); + +describe("EventForm", () => { + test("offers an unknown end rather than demanding a date", () => { + // The whole point: a form that made the end mandatory would push the reader + // into inventing one, which is the failure the parsers are forbidden from. + const html = render( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain("I don't know when it ends"); + }); + + test("lets an event be filed under a tracked game too", () => { + // A source can miss an event in a game we do cover. + const html = render( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain("Genshin Impact"); + expect(html).toContain("Limbus Company (yours)"); + }); +}); + +describe("EventRow provenance", () => { + const row = (id: string) => { + const event = { ...asDisplayEvent(OWN), id }; + return { event, clock: clockFor(event, "europe", Date.parse(AT)) }; + }; + + test("marks the reader's own event as theirs", () => { + const html = render( +
    + {}} /> +
, + ); + expect(html).toContain("yours"); + }); + + test("does not mark a scraped event as theirs", () => { + const html = render( +
    + {}} + /> +
, + ); + expect(html).not.toContain(">yours<"); + }); +});