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:
co-authored by
Claude Opus 5
parent
c161c4a0dc
commit
c5c578ae7e
@@ -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;
|
||||
},
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user