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:
co-authored by
Claude Opus 5
parent
87f309a32c
commit
04a089eb06
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user