From 60041134ee3a32a1e3039b6b7b7f9304185a7275 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 15:20:44 +0200 Subject: [PATCH] Resolve an occurrence back to the rule behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail sheet looked its record up by the row's id. For an occurrence that id carries a #date suffix and is not a key in the store, so `own` came back undefined and the edit and delete buttons vanished on every recurring row — and a save would have reached editEvent with an id it could not find and quietly done nothing. The suffix is deliberate: marks key off the occurrence so each time round carries its own completion. There is still only one record to edit. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/App.tsx | 28 ++++++++++++++++------------ src/shared/custom.ts | 20 +++++++++++++++++++- test/custom.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index a714e8a..66e7300 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -19,7 +19,7 @@ import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { adoptNewLanes, usePrefs } from "./state/usePrefs.ts"; import { snapDayWidth } from "./state/zoom.ts"; -import { useCustom } from "./state/useCustom.ts"; +import { useCustom, type EventDraft } from "./state/useCustom.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { advanceFocus, @@ -34,6 +34,7 @@ import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { metaOnTheme, useTheme } from "./state/theme.ts"; import { + recordFor, type CustomEvents, type CustomGames, type LaneId, @@ -692,17 +693,20 @@ 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, - } - } + own={(() => { + // The row may be one occurrence of a rule. Marks key off the + // occurrence; the record to edit is the rule behind it. + const record = recordFor(custom.events, openRow.event.id); + if (record === undefined) return undefined; + return { + record, + lanes: games, + games: custom.games, + onSave: (_id: string, draft: EventDraft) => + custom.editEvent(record.id, draft), + onDelete: () => custom.removeEvent(record.id), + }; + })()} /> )} diff --git a/src/shared/custom.ts b/src/shared/custom.ts index c56e253..2c1918d 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { comesRoundEarly, Repeat } from "./recurrence.ts"; +import { comesRoundEarly, Repeat, ruleIdOf } from "./recurrence.ts"; import type { Occurrence } from "./recurrence.ts"; import { EventType, GachaEvent, Precision, slugify } from "./schema.ts"; @@ -271,3 +271,21 @@ export function asOccurrenceEvent( endPrecision: occurrence.endPrecision, }; } + +/** + * The stored record a row belongs to, whichever kind of id it carries. + * + * A row may be one occurrence of a rule, whose id carries a `#` suffix and is + * deliberately not a key in the store. Marks, ignores and ticks key off that + * suffixed id — each time round has its own completion — but there is only ever + * one record to edit, and it is the rule. + * + * Total, and safe for a feed id: `ruleIdOf` returns anything without a + * separator unchanged, and a feed id is simply not in this store. + */ +export function recordFor( + events: CustomEvents, + rowId: string, +): CustomEvent | undefined { + return events[ruleIdOf(rowId)]; +} diff --git a/test/custom.test.ts b/test/custom.test.ts index 4246f7a..cb3e2e1 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -10,6 +10,7 @@ import { mintCustomEventId, mintCustomGameId, precisionOf, + recordFor, RESERVED_ID_SEGMENTS, type CustomGames, } from "../src/shared/custom.ts"; @@ -607,3 +608,35 @@ describe("expanding rules into rows", () => { expect(rows).toEqual([]); }); }); + +describe("recordFor", () => { + const rule = 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 }, + }); + const store = { [rule.id]: rule }; + + test("an occurrence row finds the rule behind it", () => { + // Marks key off the occurrence — that is what gives each time round its own + // completion — but the record to edit is the rule. Without this the detail + // sheet looks up a key that does not exist and edit and delete vanish. + expect(recordFor(store, "myevent:k3f9qa2m01#2026-09-15")?.id).toBe("myevent:k3f9qa2m01"); + }); + + test("a plain event finds itself", () => { + const plain = ownEvent({ id: "myevent:plain00001", repeat: null }); + expect(recordFor({ [plain.id]: plain }, "myevent:plain00001")?.id).toBe("myevent:plain00001"); + }); + + test("a feed event belongs to nobody here", () => { + expect(recordFor(store, "genshin:some-event:2026-09-01")).toBeUndefined(); + }); + + test("an occurrence of a rule the reader has since deleted finds nothing", () => { + expect(recordFor({}, "myevent:k3f9qa2m01#2026-09-15")).toBeUndefined(); + }); +});