From b276573ffebb76e09eda27e46857f376075af19e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 08:46:57 +0200 Subject: [PATCH] feat(recurring): datable cancellation via an "Ends" column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/export/json.test.ts | 21 ++++++ src/export/json.ts | 14 ++++ src/store/actions.test.ts | 63 +++++++++++++++++ src/store/actions.ts | 36 ++++++++-- .../routes/settings/RecurringSection.test.ts | 30 ++++++++- src/ui/routes/settings/RecurringSection.tsx | 67 +++++++++++++++++-- 6 files changed, 219 insertions(+), 12 deletions(-) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 4e436d7..cd8b2ef 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -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. diff --git a/src/export/json.ts b/src/export/json.ts index 3f6c17b..7ae743d 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -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)}"`, diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 30b9f44..a6a9c60 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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); diff --git a/src/store/actions.ts b/src/store/actions.ts index 0ef951f..0652f29 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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); } /** diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts index e6f3dad..c276506 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -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. diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index fbaba4f..1b2fb0d 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -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() { Every Measured from Starts + Ends @@ -250,6 +277,36 @@ export function RecurringSection() { }} /> + + {/* 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. */} + { + const typed = event.target.value.trim(); + if (!isValidEndedFrom(typed)) { + event.target.value = cost.endedFrom ?? ""; + return; + } + mutate((draft) => + setRecurringCostEndedFrom( + draft, + cost.id, + typed === "" ? undefined : typed, + ), + ); + }} + /> +