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 (