From 3a3b7d9c2ff1e657adf774413455c96a6a5916f1 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 28 Aug 2026 05:14:47 +0200 Subject: [PATCH 1/2] Give a reader their own events back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An event of theirs that had ended was on no surface at all. Every list and the board drop a row once its end has passed, and settings only counted the events a game held rather than naming them — so a one-off became unreachable the day it finished: impossible to edit, and impossible to delete out of a store nothing else can see. Repeating events escaped only because their occurrences roll forward. So settings names them now, under the game they were filed against, each row opening the same detail sheet a row on the front page does. Nothing about how they are managed changes; what was missing was the way back to them. Two things the index has to get right or it leaves the same hole it closes. The lists hold occurrences, never rules, so a repeating rule's own id opens nothing — nearestOccurrence bridges that, and answers for a finished series too by falling back to the first occurrence, since a rule whose `until` has passed would otherwise be exactly as stuck. And an event filed under a game we track has no row of theirs to nest under; the form allows that, so those get their own heading rather than trailing the list and reading as though they belonged to whichever game came last. Each row says why it is not on the front page — ended and when, or its cadence — because a list of bare titles leaves you guessing which of two entries is the dead one. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 ++ docs/PRD.md | 5 ++ src/client/App.tsx | 12 ++- src/client/components/YourOwn.tsx | 119 +++++++++++++++++++++++++--- src/shared/recurrence.ts | 32 ++++++++ test/controls.test.tsx | 2 + test/custom-ui.test.tsx | 127 +++++++++++++++++++++++++++++- test/recurrence.test.ts | 45 ++++++++++- 8 files changed, 335 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7ac6488..d16c912 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,11 @@ carry no source link because there is no page to send a sceptic to, and they travel in your export. Like everything else here, your browser holds the only copy. +Settings lists everything you have added, whatever state it is in, and opens +the same panel a row does. Every other surface drops an event once it has +ended, so that list is the only way back to a one-off of yours after its end +date passes — to edit it, or to get rid of it. + ### How often it comes round An event you add states its **cadence** — asked before any date, because the diff --git a/docs/PRD.md b/docs/PRD.md index 837cc61..71017e8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -317,6 +317,11 @@ Four constraints, each protecting something that already exists: - **Their events never touch the ingest pipeline.** `sanitize.ts` and `merge.ts` exist for pages we do not control; a reader's own typing is neither untrusted markup nor a second opinion to reconcile. Nothing they enter is fetched, parsed, merged, scored or quarantined. +- **They are always reachable.** Settings lists every event the reader has made, whatever state it + is in, and opens the same detail sheet a row does. Every other surface drops an event once it has + ended, so without this list a one-off of their own became unreachable the day it finished — + impossible to edit, and impossible to delete out of a store nothing else can see. Events filed + under a game we track are listed too; the form allows that, so the index has to. - **Their IDs live in their own key space.** Never `${game}:${slug}:${date}` — see `docs/DATA-MODEL.md` § Reader-authored key spaces. - **They are in the backup.** An export that omitted hand-entered events would be a lossy backup, diff --git a/src/client/App.tsx b/src/client/App.tsx index f13e96b..2ff9fea 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -30,7 +30,7 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; -import { occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts"; +import { nearestOccurrence, occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts"; import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; @@ -650,6 +650,16 @@ export function App() { onEditGame: custom.editGame, onRemoveGame: custom.removeGame, onAddEvent: custom.addEvent, + now, + // The index lists rules; the sheet opens rows. A repeating rule's + // own id is never a row — the lists hold its occurrences — so it is + // resolved to whichever occurrence is nearest before opening. + onOpen: (id) => { + const record = custom.events[id]; + const occurrence = + record === undefined ? null : nearestOccurrence(record, now); + setOpenId(occurrence === null ? id : occurrence.id); + }, }} onExport={() => exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx index d60fafe..d6016d6 100644 --- a/src/client/components/YourOwn.tsx +++ b/src/client/components/YourOwn.tsx @@ -1,16 +1,47 @@ import { useState } from "react"; -import type { CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts"; +import type { CustomEvent, CustomEvents, CustomGames, LaneId } from "../../shared/custom.ts"; +import { formatAbsolute } from "../../shared/time.ts"; import type { EventDraft } from "../state/useCustom.ts"; -import { EventForm, GameForm } from "./CustomForms.tsx"; +import { useGameMeta } from "../state/gameMeta.tsx"; +import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx"; /** * The reader's own games and events — a group of 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. + * Their events are still *managed* from the event itself — open one and the + * detail sheet offers edit and delete, exactly where you would look for them. + * What lives here is the way back **to** it, which the rest of the app cannot + * offer: every list and the board drop an event once it has ended, so a + * one-off of the reader's own became unreachable the day it finished — + * impossible to edit, and impossible to delete out of a store nothing else can + * reach. This index is the only surface that shows an event whatever state it + * is in. */ + +/** + * What a row says about itself, beyond its title. + * + * The job is to explain why an event is not on any other surface, because a + * list of bare titles leaves the reader guessing which of two entries is the + * dead one. A repeating event says how often instead of when: its dates roll + * forward, so printing one would disagree with the row they would find if they + * went looking. + */ +export function eventCaption(event: CustomEvent, nowMs: number): string { + const cadence = cadenceLabel(event.repeat); + if (cadence !== null) return cadence; + + if (event.endsAt !== null && Date.parse(event.endsAt) < nowMs) { + return `ended ${formatAbsolute(Date.parse(event.endsAt), false)}`; + } + if (Date.parse(event.startsAt) > nowMs) { + return `starts ${formatAbsolute(Date.parse(event.startsAt), false)}`; + } + return event.endsAt === null + ? "no end date" + : `until ${formatAbsolute(Date.parse(event.endsAt), false)}`; +} + export function YourOwn({ games, events, @@ -19,6 +50,8 @@ export function YourOwn({ onEditGame, onRemoveGame, onAddEvent, + now, + onOpen, }: { games: CustomGames; events: CustomEvents; @@ -28,12 +61,27 @@ export function YourOwn({ onEditGame: (id: string, name: string, hue: string) => void; onRemoveGame: (id: string) => { removed: boolean; blockedBy: number }; onAddEvent: (draft: EventDraft) => void; + now: number; + /** + * Open one of their events. Takes the stored id — a rule's, not an + * occurrence's — and the caller resolves it to whichever row the sheet can + * actually show. + */ + onOpen: (id: string) => 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); + // An event may be filed under a game we track — a source can miss one — and + // those have no row above to nest under. Listing them separately is what + // keeps this index complete: an ended event under Genshin is on no other + // surface either, and would be just as stuck. + const underTracked = Object.values(events).filter( + (e) => games[e.game] === undefined, + ); return ( // No heading or rule of its own: this is the body of a settings group that @@ -48,9 +96,8 @@ export function YourOwn({ {list.length > 0 && (
    {list.map((game) => { - const held = Object.values(events).filter( - (e) => e.game === game.id, - ).length; + const mine = Object.values(events).filter((e) => e.game === game.id); + const held = mine.length; return (
  • @@ -95,6 +142,31 @@ export function YourOwn({
    + {/* Indented under its game rather than in one flat list, + because the games are already the structure here and a + reader looking for an event of theirs knows which game they + filed it under. */} + {mine.length > 0 && ( +
      + {mine.map((event) => ( +
    • + +
    • + ))} +
    + )} + {editing === game.id && ( )} + {/* Its own heading rather than trailing the list above, which read as + though these belonged to whichever game happened to be last. */} + {underTracked.length > 0 && ( +

    Filed under a game we track

    + )} + {underTracked.length > 0 && ( +
      + {underTracked.map((event) => ( +
    • + +
    • + ))} +
    + )} + {refusal !== null && (

    {refusal}

    )} diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts index 2a5597f..f850b3d 100644 --- a/src/shared/recurrence.ts +++ b/src/shared/recurrence.ts @@ -474,6 +474,38 @@ export function nextOccurrences( return occurrencesOf(event, nowMs, horizon, count); } +/** + * The occurrence to open when the reader asks for the rule itself. + * + * The settings index lists rules, but the detail sheet opens rows, and a + * rule's own id is never a row — the lists hold its occurrences, keyed + * `myevent:#`. This is the bridge between the two. + * + * Running or next where there is one. Where there is not — a series whose + * `until` has passed — it falls back to the first occurrence rather than + * giving up, and that fallback is the point rather than a nicety: a finished + * rule with no future occurrence would otherwise be exactly as unreachable as + * the ended one-off this index exists to rescue, and just as impossible to + * delete. Which time round it lands on does not matter, because the reader + * has come to edit or delete the rule, not to inspect an occurrence. + * + * Null only when nothing repeats, and the caller has a row already: a + * non-repeating event's own id is in the lists unchanged. + */ +export function nearestOccurrence( + event: RepeatingEvent, + nowMs: number, +): Occurrence | null { + if (event.repeat === null) return null; + + const upcoming = nextOccurrences(event, nowMs, 1); + if (upcoming.length > 0) return upcoming[0]!; + + const anchor = Date.parse(event.startsAt); + if (Number.isNaN(anchor)) return null; + return occurrencesOf(event, anchor, anchor, 1)[0] ?? null; +} + /** * How many of a rule's ids the reader has actually recorded something * against — what a schedule edit that re-keys ids would strand. diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 17f5bb0..f520828 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -52,6 +52,8 @@ function render(prefs: Prefs, ignoredCount = 0): string { games: {}, events: {}, lanes: ["genshin", "hsr"], + now: 0, + onOpen: () => {}, onAddGame: () => {}, onEditGame: () => {}, onRemoveGame: () => ({ removed: true, blockedBy: 0 }), diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index cc1d7ff..c43470e 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -9,7 +9,7 @@ import { repeatFrom, strandedNotice, } from "../src/client/components/CustomForms.tsx"; -import { YourOwn } from "../src/client/components/YourOwn.tsx"; +import { eventCaption, YourOwn } from "../src/client/components/YourOwn.tsx"; import { EventRow } from "../src/client/components/EventRow.tsx"; import { EventDetail } from "../src/client/components/EventDetail.tsx"; import { AUTHOR, Colophon, REPO_URL } from "../src/client/components/Colophon.tsx"; @@ -75,6 +75,8 @@ describe("YourOwn", () => { onEditGame: noop, onRemoveGame: () => ({ removed: true, blockedBy: 0 }), onAddEvent: noop, + now: Date.parse("2026-10-01T12:00:00.000Z"), + onOpen: noop, }; test("lists a reader's game with the events it holds", () => { @@ -883,3 +885,126 @@ describe("repeatOf", () => { ).toBe(null); }); }); + +describe("eventCaption", () => { + // What each row in the settings index says about itself. The point is to + // explain why an event is not on the front page — a list of bare titles + // leaves the reader guessing which of two entries is the dead one. + const NOW = Date.parse("2026-10-01T12:00:00.000Z"); + const at = (over: Record) => + CustomEvent.parse({ + id: "myevent:k3f9qa2m01", + game: "mygame:limbus-company", + title: "Season 7", + type: "story", + summary: null, + startsAt: "2026-09-01T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-09-08T00:00:00.000Z", + endPrecision: "day", + repeat: null, + at: AT, + updatedAt: AT, + ...over, + }); + + test("a repeating event says how often, not when", () => { + // Its dates roll forward, so a date would be out of step with the row the + // reader would find if they went looking on the front page. + expect( + eventCaption(at({ repeat: { unit: "weeks", interval: 1, until: null } }), NOW), + ).toBe("on a weekly cycle"); + }); + + test("an event whose end has passed says so", () => { + // The whole reason this index exists: this one is on no other surface. + expect(eventCaption(at({}), NOW)).toContain("ended"); + }); + + test("an event still to come is not reported as ended", () => { + const caption = eventCaption( + at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }), + NOW, + ); + expect(caption).not.toContain("ended"); + }); + + test("an unannounced end is not mistaken for a passed one", () => { + const caption = eventCaption( + at({ endsAt: null, endPrecision: "unknown" }), + NOW, + ); + expect(caption).not.toContain("ended"); + }); +}); + +describe("the settings index of your own events", () => { + test("lists an ended event that no other surface shows", () => { + const ended = CustomEvent.parse({ + id: "myevent:seasonseven", + game: "mygame:limbus-company", + title: "Season 7", + type: "story", + summary: null, + startsAt: "2026-09-01T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-09-08T00:00:00.000Z", + endPrecision: "day", + repeat: null, + at: AT, + updatedAt: AT, + }); + const html = render( + {}} + onEditGame={() => {}} + onRemoveGame={() => ({ removed: true, blockedBy: 0 })} + onAddEvent={() => {}} + onOpen={() => {}} + />, + ); + expect(html).toContain("Season 7"); + expect(html).toContain("ended"); + }); +}); + +describe("the index covers events filed under a tracked game", () => { + test("an event under Genshin is listed too", () => { + // The form deliberately allows this — a source can miss an event — so an + // index that only walked the reader's own games would leave exactly the + // same hole it exists to close. + const own = CustomEvent.parse({ + id: "myevent:undergenshin", + game: "genshin", + title: "Something the wiki missed", + type: "other", + summary: null, + startsAt: "2026-09-01T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-09-08T00:00:00.000Z", + endPrecision: "day", + repeat: null, + at: AT, + updatedAt: AT, + }); + const html = render( + {}} + onEditGame={() => {}} + onRemoveGame={() => ({ removed: true, blockedBy: 0 })} + onAddEvent={() => {}} + onOpen={() => {}} + />, + ); + expect(html).toContain("Something the wiki missed"); + expect(html).toContain("ended"); + }); +}); diff --git a/test/recurrence.test.ts b/test/recurrence.test.ts index 068f119..510118b 100644 --- a/test/recurrence.test.ts +++ b/test/recurrence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts"; +import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, nearestOccurrence, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts"; import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts"; // Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and @@ -590,3 +590,46 @@ describe("cadenceOf", () => { ).toBe("custom"); }); }); + +describe("nearestOccurrence", () => { + // The settings index lists rules, but the detail sheet opens rows, and a + // rule's own id is never a row — `allRows` holds its occurrences. This is + // the bridge, and it has to answer for a dead series too: a rule whose + // `until` has passed has no future occurrence at all, and without an answer + // it would be exactly as unreachable as the ended one-off this fixes. + const rule = (over: Partial = {}): RepeatingEvent => ({ + id: "myevent:k3f9qa2m01", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: new Date("2026-09-08T09:00:00").toISOString(), + endPrecision: "exact", + repeat: { unit: "weeks", interval: 2, until: null }, + ...over, + }); + + test("a running occurrence is the nearest one", () => { + const got = nearestOccurrence(rule(), new Date("2026-09-03T12:00:00").getTime()); + expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-01"); + }); + + test("between cycles it is the one about to open", () => { + const got = nearestOccurrence(rule(), new Date("2026-09-10T12:00:00").getTime()); + expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-15"); + }); + + test("a finished series still resolves, so it can still be reached", () => { + // Any occurrence will do here: the reader has come to edit or delete the + // rule, not to inspect a particular time round. The first always exists, + // which is what makes this total. + const dead = rule({ + repeat: { unit: "weeks", interval: 2, until: new Date("2026-09-02T00:00:00").toISOString() }, + }); + const got = nearestOccurrence(dead, new Date("2027-06-01T12:00:00").getTime()); + expect(got?.id).toBe("myevent:k3f9qa2m01#2026-09-01"); + }); + + test("an event that does not repeat has no occurrence to find", () => { + // Its own id is already a row, so the caller opens it directly. + expect(nearestOccurrence(rule({ repeat: null }), Date.now())).toBe(null); + }); +}); From 86ba2ccc1dd7103a4ac71597f0bb496e6f47e7d1 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 28 Aug 2026 05:25:37 +0200 Subject: [PATCH 2/2] Make a stored id always name a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the new settings row opening nothing for a rule whose `until` precedes its `startsAt`. That parses — nothing ties the two together — and yields no occurrence at all, so nextOccurrences and the anchor fallback both come back empty, nearestOccurrence returns null, and openRow cannot resolve a bare rule id. It was the undeletable record this index exists to rescue, now with a button that lies about it. So resolution moves out of App into displayEventFor, with the rule itself as the floor: a stored id always names something the reader can edit and delete, whatever the rule does or does not generate. Exported because nothing here can click and no test renders App, which is exactly how a dead button shipped — the chain is now covered without a DOM. Two of the caption tests could not fail, proven by mutation rather than read: deleting the whole "starts" branch and removing the null-end guard both left the suite green, because `not.toContain("ended")` was never watching the branch at risk. They assert what the caption says now. And a repeating series that has stopped said only how often it repeats — in the one place whose job is explaining why an event is on no other surface. A healthy cadence explains nothing; it says when it stopped. Co-Authored-By: Claude Opus 5 (1M context) --- docs/PRD.md | 6 ++-- src/client/App.tsx | 21 +++++------ src/client/components/YourOwn.tsx | 18 ++++++++-- src/shared/custom.ts | 46 +++++++++++++++++++++++- test/custom-ui.test.tsx | 36 ++++++++++++++----- test/custom.test.ts | 58 +++++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 28 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 71017e8..60faa1b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -317,8 +317,10 @@ Four constraints, each protecting something that already exists: - **Their events never touch the ingest pipeline.** `sanitize.ts` and `merge.ts` exist for pages we do not control; a reader's own typing is neither untrusted markup nor a second opinion to reconcile. Nothing they enter is fetched, parsed, merged, scored or quarantined. -- **They are always reachable.** Settings lists every event the reader has made, whatever state it - is in, and opens the same detail sheet a row does. Every other surface drops an event once it has +- **They are reachable whenever the app is.** Settings lists every event the reader has made, + whatever state it is in, and opens the same detail sheet a row does. "Whenever the app is" is + literal: settings renders on the ready path, so a feed that fails to load takes the whole panel + with it — a pre-existing limit worth naming rather than implying otherwise. Every other surface drops an event once it has ended, so without this list a one-off of their own became unreachable the day it finished — impossible to edit, and impossible to delete out of a store nothing else can see. Events filed under a game we track are listed too; the form allows that, so the index has to. diff --git a/src/client/App.tsx b/src/client/App.tsx index 2ff9fea..354476a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -30,12 +30,12 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; -import { nearestOccurrence, occurrenceForId, strandedOccurrences } from "../shared/recurrence.ts"; +import { strandedOccurrences } from "../shared/recurrence.ts"; import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; import { - asOccurrenceEvent, + displayEventFor, recordFor, type CustomEvents, type CustomGames, @@ -369,11 +369,8 @@ export function App() { const openRow = (() => { const hit = allRows.find((r) => r.event.id === openId) ?? null; if (hit !== null || openId === null) return hit; - const record = recordFor(custom.events, openId); - if (record === undefined) return null; - const occurrence = occurrenceForId(record, openId); - if (occurrence === null) return null; - const event = asOccurrenceEvent(record, occurrence); + const event = displayEventFor(custom.events, openId, now); + if (event === null) return null; return { event, clock: clockFor(event, prefs.region, now) }; })(); @@ -654,12 +651,10 @@ export function App() { // The index lists rules; the sheet opens rows. A repeating rule's // own id is never a row — the lists hold its occurrences — so it is // resolved to whichever occurrence is nearest before opening. - onOpen: (id) => { - const record = custom.events[id]; - const occurrence = - record === undefined ? null : nearestOccurrence(record, now); - setOpenId(occurrence === null ? id : occurrence.id); - }, + // The stored id is enough: `displayEventFor` resolves a rule to + // whichever occurrence the sheet can show, and to the rule itself + // when it has none. + onOpen: setOpenId, }} onExport={() => exportProgress(prog.progress, daily.logs, ignored.marks, prefs, { diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx index d6016d6..25470de 100644 --- a/src/client/components/YourOwn.tsx +++ b/src/client/components/YourOwn.tsx @@ -29,7 +29,16 @@ import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx"; */ export function eventCaption(event: CustomEvent, nowMs: number): string { const cadence = cadenceLabel(event.repeat); - if (cadence !== null) return cadence; + if (cadence !== null) { + // A series that has stopped is exactly what this list exists to explain, + // and a healthy-looking cadence explains nothing: the reader would see + // "on a weekly cycle" and no reason it is missing from the board. + const until = event.repeat?.until ?? null; + if (until !== null && Date.parse(until) < nowMs) { + return `stopped ${formatAbsolute(Date.parse(until), false)}`; + } + return cadence; + } if (event.endsAt !== null && Date.parse(event.endsAt) < nowMs) { return `ended ${formatAbsolute(Date.parse(event.endsAt), false)}`; @@ -78,7 +87,10 @@ export function YourOwn({ // An event may be filed under a game we track — a source can miss one — and // those have no row above to nest under. Listing them separately is what // keeps this index complete: an ended event under Genshin is on no other - // surface either, and would be just as stuck. + // surface either, and would be just as stuck. It also catches an event whose + // own lane has since gone, which `removeGame` refuses to cause but an import + // can still deliver — hence "another game" rather than "a game we track", + // which would be false for exactly that row. const underTracked = Object.values(events).filter( (e) => games[e.game] === undefined, ); @@ -186,7 +198,7 @@ export function YourOwn({ {/* Its own heading rather than trailing the list above, which read as though these belonged to whichever game happened to be last. */} {underTracked.length > 0 && ( -

    Filed under a game we track

    +

    Filed under another game

    )} {underTracked.length > 0 && (
      diff --git a/src/shared/custom.ts b/src/shared/custom.ts index 2c1918d..90fce2d 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -1,5 +1,12 @@ import { z } from "zod"; -import { comesRoundEarly, Repeat, ruleIdOf } from "./recurrence.ts"; +import { + comesRoundEarly, + isOccurrenceId, + nearestOccurrence, + occurrenceForId, + Repeat, + ruleIdOf, +} from "./recurrence.ts"; import type { Occurrence } from "./recurrence.ts"; import { EventType, GachaEvent, Precision, slugify } from "./schema.ts"; @@ -289,3 +296,40 @@ export function recordFor( ): CustomEvent | undefined { return events[ruleIdOf(rowId)]; } + +/** + * The row an id names, for anything the reader entered themselves. + * + * The detail sheet opens rows, and an id reaches it from three places that do + * not agree about shape: a list row carries an occurrence id, the settings + * index carries a stored rule id, and a plain event's id is both at once. + * This is the one place that reconciles them, and it is exported rather than + * inlined because nothing in this project can click: no test renders the app, + * so an id the sheet cannot resolve was invisible to the whole suite until it + * had already shipped as a button that does nothing. + * + * **The last branch is a floor, not a nicety.** A rule can legitimately + * produce no occurrence at all — `until` earlier than `startsAt` parses fine + * and is reachable by import — which puts it on no list, no board and no + * timeline. Returning null for it would leave the settings index opening + * nothing for exactly the record that index exists to rescue. Falling back to + * the rule itself means a stored id always names something the reader can + * edit and delete. + */ +export function displayEventFor( + events: CustomEvents, + rowId: string, + nowMs: number, +): DisplayEvent | null { + const record = recordFor(events, rowId); + if (record === undefined) return null; + + if (record.repeat === null) return asDisplayEvent(record); + + const occurrence = isOccurrenceId(rowId) + ? occurrenceForId(record, rowId) + : nearestOccurrence(record, nowMs); + return occurrence === null + ? asDisplayEvent(record) + : asOccurrenceEvent(record, occurrence); +} diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index c43470e..d581835 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -921,20 +921,38 @@ describe("eventCaption", () => { expect(eventCaption(at({}), NOW)).toContain("ended"); }); - test("an event still to come is not reported as ended", () => { - const caption = eventCaption( - at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }), - NOW, - ); - expect(caption).not.toContain("ended"); + test("an event still to come says when it starts", () => { + // Asserted on what it says, not on what it avoids saying: `not.toContain` + // passed even with this whole branch deleted, because the ended branch was + // never the thing at risk. + expect( + eventCaption( + at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }), + NOW, + ), + ).toContain("starts"); }); - test("an unannounced end is not mistaken for a passed one", () => { + test("an unannounced end says so rather than reading as passed", () => { + // Date.parse(null) is NaN and NaN < now is false, so the null guard was + // unobservable through a `not.toContain("ended")` assertion. + expect(eventCaption(at({ endsAt: null, endPrecision: "unknown" }), NOW)).toBe( + "no end date", + ); + }); + + test("a repeating series that has stopped says so, not just how often", () => { + // This list's whole job is explaining why an event is on no other surface. + // A finished series that reports a healthy cadence explains nothing. const caption = eventCaption( - at({ endsAt: null, endPrecision: "unknown" }), + at({ + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "weeks", interval: 1, until: "2026-09-20T00:00:00.000Z" }, + }), NOW, ); - expect(caption).not.toContain("ended"); + expect(caption).toContain("stopped"); }); }); diff --git a/test/custom.test.ts b/test/custom.test.ts index cb3e2e1..274c282 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -10,6 +10,7 @@ import { mintCustomEventId, mintCustomGameId, precisionOf, + displayEventFor, recordFor, RESERVED_ID_SEGMENTS, type CustomGames, @@ -640,3 +641,60 @@ describe("recordFor", () => { expect(recordFor({}, "myevent:k3f9qa2m01#2026-09-15")).toBeUndefined(); }); }); + +describe("displayEventFor", () => { + // The resolution chain the detail sheet depends on, pulled out of App so it + // can be tested without a DOM. Nothing in this project can click, and no + // test renders App at all, so a row that sets an id the sheet cannot resolve + // was invisible to the whole suite — which is exactly how a settings row + // that opens nothing got shipped. + const plain = ownEvent({ id: "myevent:plain00001", repeat: null }); + const repeating = ownEvent({ + id: "myevent:k3f9qa2m01", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: new Date("2026-09-08T09:00:00").toISOString(), + endPrecision: "exact", + repeat: { unit: "weeks", interval: 2, until: null }, + }); + // Reachable only by import: nothing in the form can set `until` at all, and + // `CustomEvent` does not tie it to `startsAt`. It yields no occurrences, so + // it is on no list, no board and no timeline. + const stillborn = ownEvent({ + id: "myevent:stillborn1", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "weeks", interval: 2, until: "2020-01-01T00:00:00.000Z" }, + }); + const store = { + [plain.id]: plain, + [repeating.id]: repeating, + [stillborn.id]: stillborn, + }; + const NOW = new Date("2026-09-03T12:00:00").getTime(); + + test("a plain event resolves to itself", () => { + expect(displayEventFor(store, "myevent:plain00001", NOW)?.id).toBe("myevent:plain00001"); + }); + + test("an occurrence id resolves to that occurrence", () => { + const row = displayEventFor(store, "myevent:k3f9qa2m01#2026-09-15", NOW); + expect(row?.id).toBe("myevent:k3f9qa2m01#2026-09-15"); + }); + + test("a rule id resolves even when the rule produces no occurrence", () => { + // The finding. Its series ended before it began, so `nearestOccurrence` + // has nothing to offer — and without a floor here the settings row that + // exists to rescue this record opens nothing at all, which is worse than + // no button. + const row = displayEventFor(store, "myevent:stillborn1", NOW); + expect(row?.id).toBe("myevent:stillborn1"); + }); + + test("an id belonging to nobody resolves to nothing", () => { + expect(displayEventFor(store, "genshin:some-event:2026-09-01", NOW)).toBe(null); + expect(displayEventFor({}, "myevent:plain00001", NOW)).toBe(null); + }); +});