Make a stored id always name a row

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:25:37 +02:00
co-authored by Claude Opus 5
parent 3a3b7d9c2f
commit 86ba2ccc1d
6 changed files with 157 additions and 28 deletions
+8 -13
View File
@@ -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, {
+15 -3
View File
@@ -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 && (
<p className="mt-4 text-xs text-faint">Filed under a game we track</p>
<p className="mt-4 text-xs text-faint">Filed under another game</p>
)}
{underTracked.length > 0 && (
<ul className="mt-1.5 flex flex-col gap-1">
+45 -1
View File
@@ -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);
}