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(); + }); +});