fix(purchase): disable recurring options a month-only date would reject

Review finding I2: choosing "+ New recurring cost..." with unit "days" (or
"months" anchored to the last charge) while the purchase's date is month-only
threw a store-level error naming a field ("startDate") the purchase dialog
never shows, with no mention of the "set exact date" toggle that fixes it.

Per the controller ruling, this stops OFFERING the action instead of
rewording the throw: newCostOptionIsSelectable (src/ui/recurringForm.ts)
mirrors the store's own needsDay rule and drives `disabled` on each unit and
anchoring <option>, with a hint pointing at "set exact date". Switching to
month-only also resets a stranded selection (e.g. "days") back to the one
pairing that always works, so a previously valid choice never sits selected
in a now-disabled state.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-05 23:44:37 +02:00
co-authored by Claude Opus 5
parent 87f309a32c
commit 04a089eb06
3 changed files with 129 additions and 8 deletions
+62 -7
View File
@@ -37,7 +37,12 @@ import {
cancelScheduleFrom,
updatePurchase,
} from "../../store/actions.ts";
import { offerableCosts, seedCostFromDraft, sourceFor } from "../recurringForm.ts";
import {
newCostOptionIsSelectable,
offerableCosts,
seedCostFromDraft,
sourceFor,
} from "../recurringForm.ts";
import { WEEKDAYS } from "../routes/settings/RecurringSection.tsx";
import type { Anchoring, MonthId, Purchase, Recurrence } from "../../domain/types.ts";
@@ -280,7 +285,21 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
}}
/>
<DateModeToggle
onClick={() => setDraft((d) => ({ ...d, date: d.date.slice(0, 7) }))}
onClick={() => {
const monthOnly = draft.date.slice(0, 7);
setDraft((d) => ({ ...d, date: monthOnly }));
// Dropping to month-only can strand a "+ New recurring
// cost…" selection that needed the day just removed
// (e.g. "days", or "months" anchored to the last
// charge) — its option would show `disabled` below but
// stay selected, so reset to the one pairing that is
// always valid rather than leave state pointing at a
// choice the owner can no longer make.
if (!newCostOptionIsSelectable(newRecurrence.kind, newAnchoring, monthOnly)) {
setNewRecurrence((r) => ({ kind: "everyNMonths", n: r.n }));
setNewAnchoring("calendar");
}
}}
>
use the month only
</DateModeToggle>
@@ -425,9 +444,26 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
);
}}
>
<option value="everyNDays">days</option>
<option value="everyNWeeks">weeks</option>
<option value="everyNMonths">months</option>
<option
value="everyNDays"
disabled={!newCostOptionIsSelectable("everyNDays", newAnchoring, draft.date)}
>
days
</option>
<option
value="everyNWeeks"
disabled={!newCostOptionIsSelectable("everyNWeeks", newAnchoring, draft.date)}
>
weeks
</option>
<option
value="everyNMonths"
disabled={
!newCostOptionIsSelectable("everyNMonths", newAnchoring, draft.date)
}
>
months
</option>
</NativeSelect>
</div>
{newRecurrence.kind === "everyNWeeks" && (
@@ -463,11 +499,30 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setNewAnchoring(anchoring);
}}
>
<option value="calendar">the calendar</option>
<option value="lastCharge">the last charge</option>
<option
value="calendar"
disabled={!newCostOptionIsSelectable(newRecurrence.kind, "calendar", draft.date)}
>
the calendar
</option>
<option
value="lastCharge"
disabled={
!newCostOptionIsSelectable(newRecurrence.kind, "lastCharge", draft.date)
}
>
the last charge
</option>
</NativeSelect>
</div>
</div>
{draft.date.length <= 7 && (
<p className="text-xs text-budget-ink-muted">
Days, weeks, and "the last charge" need an exact date — use
"set exact date" next to the date above, then pick the unit
or anchoring you want.
</p>
)}
<p className="text-xs text-budget-ink-muted">
This purchase becomes its first charge. Anchored to the last
charge, its cycle restarts from today rather than from a fixed
+40 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
import { offerableCosts, seedCostFromDraft, sourceFor } from "./recurringForm.ts";
import {
newCostOptionIsSelectable,
offerableCosts,
seedCostFromDraft,
sourceFor,
} from "./recurringForm.ts";
import { createSeedDataset } from "../domain/seed.ts";
import * as actions from "../store/actions.ts";
import type { Dataset } from "../domain/types.ts";
@@ -100,6 +105,40 @@ describe("sourceFor", () => {
});
});
// I2 fix: a month-only purchase date can only ever seed "everyNMonths" +
// "calendar" (rent occurs IN a month). Every other pairing hands the date
// straight to day-precise arithmetic and would throw a store-level error
// naming a field ("startDate") the purchase dialog never shows — so the
// dialog must not OFFER those options while the date is month-only, rather
// than reword the throw. This predicate is what decides which options are
// offered; it is tested directly, since a DOM test cannot exercise a
// predicate the way it can a rendered <option disabled>.
describe("newCostOptionIsSelectable", () => {
test("everyNMonths + calendar is selectable at either granularity — rent occurs IN a month", () => {
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09")).toBe(true);
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09-20")).toBe(true);
});
test("everyNMonths + lastCharge needs a day-granular date", () => {
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNMonths", "lastCharge", "2026-09-20")).toBe(true);
});
test("everyNDays always needs a day-granular date, whichever anchoring", () => {
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNDays", "calendar", "2026-09-20")).toBe(true);
expect(newCostOptionIsSelectable("everyNDays", "lastCharge", "2026-09-20")).toBe(true);
});
test("everyNWeeks always needs a day-granular date, whichever anchoring", () => {
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09")).toBe(false);
expect(newCostOptionIsSelectable("everyNWeeks", "calendar", "2026-09-20")).toBe(true);
expect(newCostOptionIsSelectable("everyNWeeks", "lastCharge", "2026-09-20")).toBe(true);
});
});
describe("seedCostFromDraft", () => {
test("carries the purchase's amount, currency, splits and date", () => {
const draft = {
+27
View File
@@ -79,6 +79,33 @@ export function sourceFor(
* day-precisely from the charge, and the two day-granular recurrence kinds
* hand their `startDate` straight to `addDays` regardless of anchoring.
*/
/**
* Whether a NEW cost's kind+anchoring pairing can be seeded from a purchase
* dated `purchaseDate`.
*
* `seedCostFromDraft` hands `purchaseDate` straight to `startDate` whenever
* the pairing is not month-granular, so this mirrors
* `requireStartDateGranularity`'s `needsDay` in `store/actions.ts`: every
* pairing needs a real day EXCEPT "everyNMonths" anchored to "calendar" —
* rent occurs IN a month, so a month-only date is fine there.
*
* A month-only `purchaseDate` reaching any OTHER pairing would reach the
* store's own validation and throw text naming a field ("startDate") the
* purchase dialog never shows to begin with — so the control offering these
* choices must not offer one that always fails; it disables it instead
* (PurchaseDialog's "+ New recurring cost…" unit and anchoring selects).
* Exported so each option can check this without duplicating the rule, and so
* it is testable on its own — a DOM test cannot exercise a predicate.
*/
export function newCostOptionIsSelectable(
kind: Recurrence["kind"],
anchoring: Anchoring,
purchaseDate: IsoDate,
): boolean {
const needsDay = kind !== "everyNMonths" || anchoring === "lastCharge";
return !needsDay || purchaseDate.length > 7;
}
export function seedCostFromDraft(
draft: PurchaseDraft,
recurrence: Recurrence,