From c5c578ae7e223461ef77ff7a96a91eeca6cdaf1d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 15:30:49 +0200 Subject: [PATCH] Say how often, and say what a reschedule costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Occurrence ids carry their own start day, so moving the anchor or the interval re-keys every occurrence and the marks under the old ids stop being reachable. Nothing is rewritten — removeEvent makes the same trade, and useMarkSet never removes because nothing else holds a copy — but the reader is told the count first, the way removeGame reports blockedBy instead of cascading. Informs, never blocks. Renaming still costs nothing: the token is random precisely so fixing a typo never moves an id, and movesOccurrences is what keeps the warning off a rename and off a bare change of `until`. cadenceLabel sits beside the form's own vocabulary so the sheet cannot describe a rule differently from the control that set it. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/App.tsx | 14 +++++ src/client/components/CustomForms.tsx | 79 ++++++++++++++++++++++----- src/client/components/EventDetail.tsx | 8 ++- test/custom-ui.test.tsx | 55 ++++++++++++++++++- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 66e7300..5fc8a0a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -30,6 +30,7 @@ import { } from "./state/lens.ts"; import { clockFor, formatRemaining } from "../shared/time.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; +import { nextOccurrences } 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"; @@ -705,6 +706,19 @@ export function App() { onSave: (_id: string, draft: EventDraft) => custom.editEvent(record.id, draft), onDelete: () => custom.removeEvent(record.id), + strandedBy: () => { + // What the reader has actually recorded against the occurrences + // this rule generates today, and would no longer reach once the + // ids move. Twelve is a season of a fortnightly rule — enough to + // make the number meaningful without walking a decade of a + // daily one. + if (record.repeat === null) return 0; + return nextOccurrences(record, now, 12).filter( + (o) => + prog.progress[o.id] !== undefined || + (daily.logs[o.id]?.days.length ?? 0) > 0, + ).length; + }, }; })()} /> diff --git a/src/client/components/CustomForms.tsx b/src/client/components/CustomForms.tsx index 3564264..7fd7136 100644 --- a/src/client/components/CustomForms.tsx +++ b/src/client/components/CustomForms.tsx @@ -5,7 +5,12 @@ import { type CustomGames, type LaneId, } from "../../shared/custom.ts"; -import { comesRoundEarly, RepeatUnit } from "../../shared/recurrence.ts"; +import { + comesRoundEarly, + movesOccurrences, + RepeatUnit, + type Repeat, +} from "../../shared/recurrence.ts"; import { EventType } from "../../shared/schema.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { readerInstant, type EventDraft } from "../state/useCustom.ts"; @@ -41,6 +46,32 @@ export const CUSTOM_HUES = [ const TYPES = EventType.options; +/** + * What a schedule change costs, or null when it costs nothing. + * + * Occurrence ids carry their own start day, so moving the anchor or the + * interval re-keys every occurrence and the marks stored under the old ids stop + * being reachable. Nothing is rewritten — `removeEvent` makes the same trade, + * and `useMarkSet.merge` never removes because nothing else holds a copy — but + * the reader is told the count first, the way `removeGame` reports `blockedBy` + * instead of cascading. + * + * Informs; never blocks. + */ +export function strandedNotice(count: number): string | null { + if (count <= 0) return null; + return `Changing the schedule will strand ${count} tick${ + count === 1 ? "" : "s" + } you've already recorded.`; +} + +/** How often a rule comes round, in the words the form offered. */ +export function cadenceLabel(repeat: Repeat | null): string | null { + if (repeat === null) return null; + if (repeat.interval === 1) return `every ${repeat.unit.replace(/s$/, "")}`; + return `every ${repeat.interval} ${repeat.unit}`; +} + function labelClass(): string { return "block text-xs font-medium text-muted"; } @@ -136,6 +167,7 @@ export function EventForm({ initial, onSave, onCancel, + strandedBy, }: { /** Every lane an event can belong to — a source can miss an event too. */ lanes: LaneId[]; @@ -143,6 +175,14 @@ export function EventForm({ initial?: CustomEvent | undefined; onSave: (draft: EventDraft) => void; onCancel: () => void; + /** + * How many stored marks this draft's schedule would leave behind. + * + * Supplied by the caller because only it can see the mark stores. Absent — + * on the add form, where there is nothing to strand — the notice never + * renders. + */ + strandedBy?: ((draft: EventDraft) => number) | undefined; }) { const gameMeta = useGameMeta(); const start = fields(initial?.startsAt ?? null); @@ -217,23 +257,32 @@ export function EventForm({ !earlyReturn && (repeatUnit === "never" || intervalValid); + const draft: EventDraft | null = + startsAt === null + ? null + : { + game, title, type, + summary: summary === "" ? null : summary, + startsAt, startHasTime: startTime !== "", + endsAt, endHasTime: endTime !== "", + repeat, + }; + // Only a schedule change re-keys anything. Renaming does not — the token is + // random precisely so fixing a typo never costs the marks attached to it. + const stranded = + initial !== undefined && draft !== null && strandedBy !== undefined && + movesOccurrences(initial, draft) + ? strandedBy(draft) + : 0; + const notice = strandedNotice(stranded); + return (
{ e.preventDefault(); - if (!valid || startsAt === null) return; - onSave({ - game, - title, - type, - summary, - startsAt, - startHasTime: startTime !== "", - endsAt, - endHasTime: endTime !== "", - repeat, - }); + if (!valid || draft === null) return; + onSave(draft); }} >