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:
1 parent
9fe950bc7d
commit
b276573ffe
6 files changed
+219
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)}"`,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user