feat(recurring): datable cancellation via an "Ends" column

Gap 2 (spec §10): endedFrom is typed as any IsoDate and the spec calls it
"cancellation", but Settings only ever wrote currentMonth — backdating or
forward-dating a cancellation was import-only.

Add an "Ends" column beside "Starts" in the Recurring costs table, edited and
validated the same way (isValidEndedFrom — no granularity rule, per §10, but
still refuses a calendar-impossible date like "2026-02-30"). Empty means "not
ended" and clears the cancellation.

Coherence rule: `archived` mirrors "endedFrom is set". setRecurringCostEndedFrom
is the one place that now enforces it — endRecurringCost and
restoreRecurringCost (the one-click row buttons) delegate to it, so the column
and the buttons can never leave a bill dimmed-but-live or ended-but-undimmed.

Also closes the same calendar-impossible gap in the JSON importer, which
checked endedFrom's shape but not its calendar validity, unlike startDate.
This commit is contained in:
Lucas Winther committed 2026-09-04 08:46:57 +02:00
1 parent 9fe950bc7d
commit b276573ffe
6 files changed
+219 -12

No files matched your search

+21
View File
@@ -302,6 +302,27 @@ describe("recurring cost validation", () => {
.toThrow(/ended-from date/);
});
// Same residual-C1 gap as startDate: PURCHASE_DATE checks shape and digit
// range, not calendar validity, so "2026-02-30" passes it. §10: endedFrom
// carries no granularity rule, but it must still be a real date.
test("a calendar-impossible endedFrom is refused (day- or month-shaped)", () => {
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-30" }])))
.toThrow(/calendar-impossible ended-from/);
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-29" }]))) // not a leap year
.toThrow(/calendar-impossible ended-from/);
});
test("a valid endedFrom of either granularity is accepted, regardless of recurrence kind", () => {
expect(
parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-06" }])).recurring.length,
).toBe(1);
expect(
parseDatasetJson(
withRecurring([{ ...valid, endedFrom: "2026-06-17" }]), // month-only recurrence, day-granular end
).recurring.length,
).toBe(1);
});
test("an interval below 1 is refused, not clamped", () => {
// Clamping would silently change the bill's schedule. The walk in
// occurrences.ts cannot terminate without this.
+14
View File
@@ -367,6 +367,20 @@ export function parseDatasetJson(text: string): Dataset {
);
}
// `endedFrom` carries no granularity rule — checked above by not
// requiring it to match the recurrence kind's day-granularity — but it
// still has to be a real calendar date, the same way `startDate` does.
if (cost.endedFrom !== undefined) {
try {
if (DAY_GRANULAR_DATE.test(cost.endedFrom)) toDayOrdinal(cost.endedFrom);
else monthOf(cost.endedFrom);
} catch (error) {
throw new ImportValidationError(
`${label} has a calendar-impossible ended-from date "${cost.endedFrom}" (${(error as Error).message})`,
);
}
}
if (!ANCHORINGS.has(cost.anchoring)) {
throw new ImportValidationError(
`${label} has an unknown anchoring "${String(cost.anchoring)}"`,
+63
View File
@@ -1,6 +1,7 @@
import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
import type { Dataset } from "../domain/types.ts";
/**
@@ -684,6 +685,68 @@ describe("recurring costs", () => {
expect(data.recurring[0]!.archived).toBe(false);
});
describe("setRecurringCostEndedFrom", () => {
test("setting a date ends AND archives, whether it is past or future", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.setRecurringCostEndedFrom(data, cost.id, "2020-01"); // past
expect(data.recurring[0]!.endedFrom).toBe("2020-01");
expect(data.recurring[0]!.archived).toBe(true);
actions.setRecurringCostEndedFrom(data, cost.id, "2099-06"); // future
expect(data.recurring[0]!.endedFrom).toBe("2099-06");
expect(data.recurring[0]!.archived).toBe(true);
});
test("clearing (undefined) un-ends AND un-archives — the column doubles as un-end", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.setRecurringCostEndedFrom(data, cost.id, "2026-06");
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
expect(data.recurring[0]!.endedFrom).toBeUndefined();
expect(data.recurring[0]!.archived).toBe(false);
});
test("a past or future endedFrom set through the store action takes effect in the projection", () => {
// Integration check for the "Ends" column: the store write and the
// domain walk agree, for both directions — a backdated cancellation
// (import-only before this change) and a forward-dated one.
const past = draft();
const pastCost = actions.addRecurringCost(past, rentInput); // starts 2026-01
actions.setRecurringCostEndedFrom(past, pastCost.id, "2026-03"); // backdated
const pastOccurrences = occurrencesByMonth(past, "2026-06").get("2026-01") ?? [];
expect(pastOccurrences.length).toBe(1); // Jan still occurred
expect(occurrencesByMonth(past, "2026-06").has("2026-03")).toBe(false); // Mar+ stopped
const future = draft();
const futureCost = actions.addRecurringCost(future, rentInput);
actions.setRecurringCostEndedFrom(future, futureCost.id, "2026-08"); // forward-dated
const futureByMonth = occurrencesByMonth(future, "2026-09");
expect(futureByMonth.has("2026-07")).toBe(true); // still occurs right up to the cutoff
expect(futureByMonth.has("2026-08")).toBe(false); // stopped from the cutoff onward
});
test("endRecurringCost and restoreRecurringCost now delegate here, so all three stay coherent", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.endRecurringCost(data, cost.id, "2026-06");
expect(data.recurring[0]!.archived).toBe(true);
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
expect(data.recurring[0]!.archived).toBe(false);
actions.setRecurringCostEndedFrom(data, cost.id, "2027-01");
expect(data.recurring[0]!.archived).toBe(true);
actions.restoreRecurringCost(data, cost.id);
expect(data.recurring[0]!.endedFrom).toBeUndefined();
expect(data.recurring[0]!.archived).toBe(false);
});
});
test("moving swaps order with its neighbour", () => {
const data = draft();
const first = actions.addRecurringCost(data, rentInput);
+29 -7
View File
@@ -497,7 +497,11 @@ export function moveRecurringCost(
}
/**
* Stops a bill from `from` onward and dims it in the list.
* Sets or clears `endedFrom` directly — what the Settings "Ends" column edits
* — keeping `archived` coherent with it. The rule: `archived` mirrors
* "`endedFrom` is set", same as `endRecurringCost`/`restoreRecurringCost`
* below, which now both delegate here. That is what stops the two fields
* diverging into a bill that reads as live but is dimmed, or ended but not.
*
* Both fields, because they mean different things: `endedFrom` stops the
* PROJECTION, and `archived` only flags the row — same as a post, it stays
@@ -505,17 +509,35 @@ export function moveRecurringCost(
* out. Archiving alone would leave the bill projecting forever; setting
* `endedFrom` alone would leave a dead bill undimmed in the list. Neither
* touches a past occurrence, so no historical figure moves.
*
* `endedFrom` itself is unchecked here — no granularity rule applies to it
* (§10; it is only ever compared lexicographically, never reaches
* `addDays`) — so the caller validates it is a real `IsoDate` before this is
* called, the same way the "Starts" field already does for `startDate`.
*/
export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void {
export function setRecurringCostEndedFrom(
draft: Dataset,
id: RecurringCostId,
endedFrom: IsoDate | undefined,
): void {
const cost = requireRecurringCost(draft, id);
cost.endedFrom = from;
cost.archived = true;
if (endedFrom === undefined) {
delete cost.endedFrom;
cost.archived = false;
} else {
cost.endedFrom = endedFrom;
cost.archived = true;
}
}
/** The one-click convenience: stop a bill from `from` onward and dim it. */
export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void {
setRecurringCostEndedFrom(draft, id, from);
}
/** The one-click convenience: clear the cancellation and un-dim the row. */
export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void {
const cost = requireRecurringCost(draft, id);
delete cost.endedFrom;
cost.archived = false;
setRecurringCostEndedFrom(draft, id, undefined);
}
/**
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
@@ -52,6 +52,34 @@ describe("isValidStartDate", () => {
});
});
// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but
// still has to be a real calendar date, and empty must be accepted since it
// is how the field un-ends a cost.
describe("isValidEndedFrom", () => {
test("an empty string is valid — it means 'not ended'", () => {
expect(isValidEndedFrom("")).toBe(true);
});
test("both granularities are valid, unconditionally — no recurrence kind is involved", () => {
expect(isValidEndedFrom("2026-09")).toBe(true);
expect(isValidEndedFrom("2026-09-17")).toBe(true);
});
test("garbage is rejected", () => {
expect(isValidEndedFrom("not-a-date")).toBe(false);
expect(isValidEndedFrom("2026-13")).toBe(false);
});
test("a calendar-impossible date is rejected, day- or month-shaped", () => {
expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30
expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year
});
test("a real leap day is accepted", () => {
expect(isValidEndedFrom("2024-02-29")).toBe(true);
});
});
// C1: switching the unit dropdown to a day-granular kind must expand a
// month-only startDate in the same mutate call, never leaving the pairing
// unvalidated for even one render.
+63 -4
View File
@@ -9,6 +9,7 @@ import {
endRecurringCost,
moveRecurringCost,
restoreRecurringCost,
setRecurringCostEndedFrom,
updateRecurringCost,
} from "../../../store/actions.ts";
import { parseMoneyInput } from "../../moneyInput.ts";
@@ -53,6 +54,31 @@ export function isValidStartDate(value: string, kind: Recurrence["kind"]): boole
}
}
/**
* Validates the "Ends" column. Unlike `startDate`, `endedFrom` carries NO
* granularity rule (§10 of the design) — it is only ever compared
* lexicographically via `isBefore` in `domain/occurrences.ts` and never
* reaches `addDays`, so a month-only cancellation is exactly as valid as a
* day-granular one regardless of the cost's recurrence kind. It still has to
* be a real calendar date: "2026-02-30" is refused the same way an
* impossible `startDate` already is.
*
* An empty string is valid — it means "clear the cancellation" — and is what
* lets the field double as the un-end control.
*
* Exported for `RecurringSection.test.ts`.
*/
export function isValidEndedFrom(value: string): boolean {
if (value === "") return true;
try {
if (isDayGranular(value)) toDayOrdinal(value);
else monthOf(value);
return true;
} catch {
return false;
}
}
/**
* What the "Starts" value becomes when the owner switches to a day-granular
* unit: a month-only value expands to the first of that month, in the SAME
@@ -190,6 +216,7 @@ export function RecurringSection() {
<th className="py-2 font-medium">Every</th>
<th className="py-2 font-medium">Measured from</th>
<th className="py-2 font-medium">Starts</th>
<th className="py-2 font-medium">Ends</th>
<th className="py-2" />
</tr>
</thead>
@@ -250,6 +277,36 @@ export function RecurringSection() {
}}
/>
</td>
<td className="py-2">
{/* Same idiom as "Starts": uncontrolled, committed on
blur, keyed on the stored value so an external change
(the end/restart button, or a rejected edit) shows up
rather than leaving stale typed text. Empty means "not
ended" and is itself a valid value — clearing it here
does the same thing as the "restart" button. */}
<Input
key={`${cost.id}-ends-${cost.endedFrom ?? ""}`}
className="h-8 w-32"
type="text"
placeholder="—"
aria-label={`Ends date for ${cost.name}`}
defaultValue={cost.endedFrom ?? ""}
onBlur={(event) => {
const typed = event.target.value.trim();
if (!isValidEndedFrom(typed)) {
event.target.value = cost.endedFrom ?? "";
return;
}
mutate((draft) =>
setRecurringCostEndedFrom(
draft,
cost.id,
typed === "" ? undefined : typed,
),
);
}}
/>
</td>
<td className="py-2">
<div className="flex items-center justify-end gap-1">
<Button
@@ -292,10 +349,12 @@ export function RecurringSection() {
)}
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
Ending a cost stops it from this month onward and leaves every past
occurrence exactly as it was. Changing the amount only moves occurrences
that have not been confirmed yet — what you already paid is recorded on
the purchases themselves, so a price rise never rewrites history.
Ending a cost — from the "end" button, or by typing a date into
"Ends" directly — stops it from that date onward and leaves every past
occurrence exactly as it was; clearing the date restarts it. Changing
the amount only moves occurrences that have not been confirmed yet —
what you already paid is recorded on the purchases themselves, so a
price rise never rewrites history.
</p>
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">