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) <noreply@anthropic.com>
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