feat(purchase): tag a purchase to a recurring cost, or create one from it

Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.

"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.

A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-05 23:32:38 +02:00
co-authored by Claude Opus 5
parent ccde30b8ea
commit 87f309a32c
4 changed files with 437 additions and 7 deletions
+183 -6
View File
@@ -31,8 +31,18 @@ import {
import { useDataset } from "../hooks/useDataset.ts";
import { parseMoneyInput } from "../moneyInput.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
import type { MonthId, Purchase } from "../../domain/types.ts";
import {
addPurchase,
addRecurringCost,
cancelScheduleFrom,
updatePurchase,
} from "../../store/actions.ts";
import { offerableCosts, seedCostFromDraft, sourceFor } from "../recurringForm.ts";
import { WEEKDAYS } from "../routes/settings/RecurringSection.tsx";
import type { Anchoring, MonthId, Purchase, Recurrence } from "../../domain/types.ts";
/** The "Recurring cost" select's own sentinel, alongside "none" and a cost id. */
const NEW_COST = "__new__";
interface Props {
monthId: MonthId;
@@ -65,13 +75,40 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
);
const bulkMode = !purchase && mode === "many";
// "none" | a RecurringCostId | NEW_COST. Seeded from the purchase's own
// association when editing, so the control shows what is already there.
const [recurringSelection, setRecurringSelection] = useState<string>(
() => purchase?.source?.recurringId ?? "none",
);
// The shapes NEW_COST reveals, same defaults RecurringSection's own
// "Add recurring cost" quick-add uses.
const [newRecurrence, setNewRecurrence] = useState<Recurrence>({
kind: "everyNMonths",
n: 1,
});
const [newAnchoring, setNewAnchoring] = useState<Anchoring>("calendar");
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
// The costs offerable for the date currently typed, plus whatever is
// already selected — so picking a cost, then changing the date to one its
// grid does not cover, does not silently drop the selection out of the
// list out from under the owner.
const offerable = offerableCosts(dataset, draft.date);
const selected = dataset.recurring.find((cost) => cost.id === recurringSelection);
const selectableCosts =
selected && !offerable.some((cost) => cost.id === selected.id)
? [...offerable, selected]
: offerable;
function reset() {
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? ""));
setRecurringSelection(purchase?.source?.recurringId ?? "none");
setNewRecurrence({ kind: "everyNMonths", n: 1 });
setNewAnchoring("calendar");
}
// A write can be REFUSED, not merely fail on a full disk: editing the date
@@ -82,12 +119,35 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
function save() {
if (errors.length > 0) return;
// One mutate for the whole batch: a single write, one queue entry, and no
// way to half-commit a shopping trip.
// way to half-commit a shopping trip. Creating a NEW recurring cost is
// part of that same batch — the cost and its first charge must land
// together, or a failure could leave a cost with no first charge.
const created = bulkMode ? toPurchases(bulk) : null;
void mutate((data) => {
if (created) for (const p of created) addPurchase(data, p);
else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
else addPurchase(data, toPurchase(draft));
if (created) {
for (const p of created) addPurchase(data, p);
return;
}
let source: Purchase["source"];
if (recurringSelection === NEW_COST) {
const seeded = addRecurringCost(
data,
seedCostFromDraft(draft, newRecurrence, newAnchoring),
);
source = sourceFor(data, seeded, draft.date);
} else if (recurringSelection !== "none") {
const cost = data.recurring.find((c) => c.id === recurringSelection);
if (cost) source = sourceFor(data, cost, draft.date);
}
if (purchase) {
// `source` is passed explicitly, even when undefined: updatePurchase
// only touches keys present in `changes`, so omitting it entirely
// when the owner picks "None" would leave a stored association
// untouched instead of clearing it.
updatePurchase(data, purchase.id, { ...toPurchase(draft), source });
} else {
addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) });
}
}).then((saved) => {
if (!saved) return;
setOpen(false);
@@ -299,6 +359,123 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
/>
</div>
<div className="space-y-1">
<Label htmlFor="purchase-recurring">Recurring cost</Label>
<NativeSelect
id="purchase-recurring"
value={recurringSelection}
onChange={(event) => {
const value = event.target.value;
setRecurringSelection(value);
}}
>
<option value="none">None</option>
{selectableCosts.map((cost) => (
<option key={cost.id} value={cost.id}>
{cost.name}
</option>
))}
<option value={NEW_COST}>+ New recurring cost…</option>
</NativeSelect>
<p className="text-xs text-budget-ink-muted">
Tag this purchase as a charge for one of your bills: for one
measured from the last charge, that also restarts its cycle
from today.
</p>
</div>
{recurringSelection === NEW_COST && (
<fieldset className="space-y-3 rounded-md border border-budget-rule p-3">
<legend className="px-1 text-xs font-medium text-budget-ink-muted">
New recurring cost
</legend>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="new-recurring-interval">Every</Label>
<Input
id="new-recurring-interval"
type="number"
min={1}
step={1}
className="h-8 w-20"
value={newRecurrence.n}
onChange={(event) => {
// Captured before setState, same discipline mutate
// callbacks need — this one is not deferred, but there
// is no reason to read the live DOM node twice.
const typed = event.target.value;
const n = Number(typed);
if (!Number.isInteger(n) || n < 1) return;
setNewRecurrence((r) => ({ ...r, n }));
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-recurring-unit">Unit</Label>
<NativeSelect
id="new-recurring-unit"
className="h-8 w-auto"
value={newRecurrence.kind}
onChange={(event) => {
const kind = event.target.value as Recurrence["kind"];
setNewRecurrence((r) =>
kind === "everyNWeeks"
? { kind, n: r.n, weekday: 1 }
: { kind, n: r.n },
);
}}
>
<option value="everyNDays">days</option>
<option value="everyNWeeks">weeks</option>
<option value="everyNMonths">months</option>
</NativeSelect>
</div>
{newRecurrence.kind === "everyNWeeks" && (
<div className="space-y-1">
<Label htmlFor="new-recurring-weekday">Weekday</Label>
<NativeSelect
id="new-recurring-weekday"
className="h-8 w-auto"
value={newRecurrence.weekday}
onChange={(event) => {
const weekday = Number(event.target.value);
setNewRecurrence((r) =>
r.kind === "everyNWeeks" ? { ...r, weekday } : r,
);
}}
>
{WEEKDAYS.map((day, index) => (
<option key={day} value={index}>
{day}
</option>
))}
</NativeSelect>
</div>
)}
<div className="space-y-1">
<Label htmlFor="new-recurring-anchoring">Measured from</Label>
<NativeSelect
id="new-recurring-anchoring"
className="h-8 w-auto"
value={newAnchoring}
onChange={(event) => {
const anchoring = event.target.value as Anchoring;
setNewAnchoring(anchoring);
}}
>
<option value="calendar">the calendar</option>
<option value="lastCharge">the last charge</option>
</NativeSelect>
</div>
</div>
<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
grid.
</p>
</fieldset>
)}
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
<PlanEditor draft={draft} onChange={setDraft} />