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";
|
} 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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})()}
|
})()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user