Say how often, and say what a reschedule costs

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent c161c4a0dc
commit c5c578ae7e
4 changed files with 141 additions and 15 deletions
+14
View File
@@ -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;
},
};
})()}
/>
+66 -13
View File
@@ -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 (
<form
className="mt-3 rounded-xl border border-hairline p-3"
onSubmit={(e) => {
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);
}}
>
<label className={labelClass()}>
@@ -420,6 +469,10 @@ export function EventForm({
</p>
)}
{notice !== null && (
<p className="mt-2 text-xs leading-relaxed text-muted">{notice}</p>
)}
<div className="mt-4 flex gap-2">
<button
type="submit"
+7 -1
View File
@@ -7,7 +7,7 @@ import {
type LaneId,
} from "../../shared/custom.ts";
import type { EventDraft } from "../state/useCustom.ts";
import { EventForm } from "./CustomForms.tsx";
import { cadenceLabel, EventForm } from "./CustomForms.tsx";
import {
formatAbsolute,
formatRemaining,
@@ -76,6 +76,7 @@ export function EventDetail({
games: CustomGames;
onSave: (id: string, draft: EventDraft) => void;
onDelete: (id: string) => void;
strandedBy: (draft: EventDraft) => number;
}
| undefined;
}) {
@@ -159,6 +160,10 @@ export function EventDetail({
<Field label="Type">{event.type}</Field>
</dl>
{cadenceLabel(own?.record.repeat ?? null) !== null && (
<p className="text-xs text-faint">{cadenceLabel(own!.record.repeat)}</p>
)}
{risk !== "fine" && effort !== undefined && clock.msRemaining !== null && (
<p
className="mt-4 rounded-lg border px-3 py-2 text-xs leading-relaxed"
@@ -241,6 +246,7 @@ export function EventDetail({
setEditing(false);
}}
onCancel={() => setEditing(false)}
strandedBy={own.strandedBy}
/>
) : (
<div className="flex gap-2">
+54 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EventForm } from "../src/client/components/CustomForms.tsx";
import {
cadenceLabel,
EventForm,
strandedNotice,
} from "../src/client/components/CustomForms.tsx";
import { YourOwn } from "../src/client/components/YourOwn.tsx";
import { EventRow } from "../src/client/components/EventRow.tsx";
import { AUTHOR, Colophon, REPO_URL } from "../src/client/components/Colophon.tsx";
@@ -415,3 +419,52 @@ describe("stating a repeat", () => {
expect(html).toContain("no countdown");
});
});
describe("what a reschedule costs", () => {
test("says nothing when nothing would be stranded", () => {
expect(strandedNotice(0)).toBe(null);
});
test("counts, and agrees with itself about plurals", () => {
expect(strandedNotice(1)).toContain("1 tick");
expect(strandedNotice(1)).not.toContain("ticks");
expect(strandedNotice(3)).toContain("3 ticks");
});
test("says what happens, not what is forbidden", () => {
// It informs; it never blocks. Their data is theirs to reorganise, and a
// form that refused the edit would be a worse answer than one that says
// what it costs — removeGame refuses because a cascade is unrecoverable,
// and an orphaned mark is not.
expect(strandedNotice(3)!.toLowerCase()).toContain("strand");
});
});
describe("the sheet says how often", () => {
test("a repeating event shows its cadence", () => {
const rule = CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Abyss",
type: "challenge",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-08T00:00:00.000Z",
endPrecision: "day",
repeat: { unit: "weeks", interval: 2, until: null },
at: AT,
updatedAt: AT,
});
expect(cadenceLabel(rule.repeat)).toBe("every 2 weeks");
});
test("an interval of one drops the number and the plural", () => {
expect(cadenceLabel({ unit: "weeks", interval: 1, until: null })).toBe("every week");
expect(cadenceLabel({ unit: "months", interval: 1, until: null })).toBe("every month");
});
test("a non-repeating event has no cadence to show", () => {
expect(cadenceLabel(null)).toBe(null);
});
});