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"; } from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts"; import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { nextOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts"; import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts"; import { metaOnTheme, useTheme } from "./state/theme.ts";
@@ -705,6 +706,19 @@ export function App() {
onSave: (_id: string, draft: EventDraft) => onSave: (_id: string, draft: EventDraft) =>
custom.editEvent(record.id, draft), custom.editEvent(record.id, draft),
onDelete: () => custom.removeEvent(record.id), 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 CustomGames,
type LaneId, type LaneId,
} from "../../shared/custom.ts"; } 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 { EventType } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx"; import { useGameMeta } from "../state/gameMeta.tsx";
import { readerInstant, type EventDraft } from "../state/useCustom.ts"; import { readerInstant, type EventDraft } from "../state/useCustom.ts";
@@ -41,6 +46,32 @@ export const CUSTOM_HUES = [
const TYPES = EventType.options; 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 { function labelClass(): string {
return "block text-xs font-medium text-muted"; return "block text-xs font-medium text-muted";
} }
@@ -136,6 +167,7 @@ export function EventForm({
initial, initial,
onSave, onSave,
onCancel, onCancel,
strandedBy,
}: { }: {
/** Every lane an event can belong to — a source can miss an event too. */ /** Every lane an event can belong to — a source can miss an event too. */
lanes: LaneId[]; lanes: LaneId[];
@@ -143,6 +175,14 @@ export function EventForm({
initial?: CustomEvent | undefined; initial?: CustomEvent | undefined;
onSave: (draft: EventDraft) => void; onSave: (draft: EventDraft) => void;
onCancel: () => 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 gameMeta = useGameMeta();
const start = fields(initial?.startsAt ?? null); const start = fields(initial?.startsAt ?? null);
@@ -217,23 +257,32 @@ export function EventForm({
!earlyReturn && !earlyReturn &&
(repeatUnit === "never" || intervalValid); (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 ( return (
<form <form
className="mt-3 rounded-xl border border-hairline p-3" className="mt-3 rounded-xl border border-hairline p-3"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!valid || startsAt === null) return; if (!valid || draft === null) return;
onSave({ onSave(draft);
game,
title,
type,
summary,
startsAt,
startHasTime: startTime !== "",
endsAt,
endHasTime: endTime !== "",
repeat,
});
}} }}
> >
<label className={labelClass()}> <label className={labelClass()}>
@@ -420,6 +469,10 @@ export function EventForm({
</p> </p>
)} )}
{notice !== null && (
<p className="mt-2 text-xs leading-relaxed text-muted">{notice}</p>
)}
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button
type="submit" type="submit"
+7 -1
View File
@@ -7,7 +7,7 @@ import {
type LaneId, type LaneId,
} from "../../shared/custom.ts"; } from "../../shared/custom.ts";
import type { EventDraft } from "../state/useCustom.ts"; import type { EventDraft } from "../state/useCustom.ts";
import { EventForm } from "./CustomForms.tsx"; import { cadenceLabel, EventForm } from "./CustomForms.tsx";
import { import {
formatAbsolute, formatAbsolute,
formatRemaining, formatRemaining,
@@ -76,6 +76,7 @@ export function EventDetail({
games: CustomGames; games: CustomGames;
onSave: (id: string, draft: EventDraft) => void; onSave: (id: string, draft: EventDraft) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
strandedBy: (draft: EventDraft) => number;
} }
| undefined; | undefined;
}) { }) {
@@ -159,6 +160,10 @@ export function EventDetail({
<Field label="Type">{event.type}</Field> <Field label="Type">{event.type}</Field>
</dl> </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 && ( {risk !== "fine" && effort !== undefined && clock.msRemaining !== null && (
<p <p
className="mt-4 rounded-lg border px-3 py-2 text-xs leading-relaxed" className="mt-4 rounded-lg border px-3 py-2 text-xs leading-relaxed"
@@ -241,6 +246,7 @@ export function EventDetail({
setEditing(false); setEditing(false);
}} }}
onCancel={() => setEditing(false)} onCancel={() => setEditing(false)}
strandedBy={own.strandedBy}
/> />
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
+54 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server"; 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 { YourOwn } from "../src/client/components/YourOwn.tsx";
import { EventRow } from "../src/client/components/EventRow.tsx"; import { EventRow } from "../src/client/components/EventRow.tsx";
import { AUTHOR, Colophon, REPO_URL } from "../src/client/components/Colophon.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"); 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);
});
});