From 87f309a32c52d795747baac5edccdcf5c3ca450a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 23:32:38 +0200 Subject: [PATCH] feat(purchase): tag a purchase to a recurring cost, or create one from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/components/PurchaseDialog.tsx | 189 +++++++++++++++++++- src/ui/recurringForm.test.ts | 154 ++++++++++++++++ src/ui/recurringForm.ts | 98 ++++++++++ src/ui/routes/settings/RecurringSection.tsx | 3 +- 4 files changed, 437 insertions(+), 7 deletions(-) create mode 100644 src/ui/recurringForm.test.ts create mode 100644 src/ui/recurringForm.ts diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index ec4a814..4e3d8e2 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -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( + () => purchase?.source?.recurringId ?? "none", + ); + // The shapes NEW_COST reveals, same defaults RecurringSection's own + // "Add recurring cost" quick-add uses. + const [newRecurrence, setNewRecurrence] = useState({ + kind: "everyNMonths", + n: 1, + }); + const [newAnchoring, setNewAnchoring] = useState("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) { /> +
+ + { + const value = event.target.value; + setRecurringSelection(value); + }} + > + + {selectableCosts.map((cost) => ( + + ))} + + +

+ 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. +

+
+ + {recurringSelection === NEW_COST && ( +
+ + New recurring cost + +
+
+ + { + // 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 })); + }} + /> +
+
+ + { + const kind = event.target.value as Recurrence["kind"]; + setNewRecurrence((r) => + kind === "everyNWeeks" + ? { kind, n: r.n, weekday: 1 } + : { kind, n: r.n }, + ); + }} + > + + + + +
+ {newRecurrence.kind === "everyNWeeks" && ( +
+ + { + const weekday = Number(event.target.value); + setNewRecurrence((r) => + r.kind === "everyNWeeks" ? { ...r, weekday } : r, + ); + }} + > + {WEEKDAYS.map((day, index) => ( + + ))} + +
+ )} +
+ + { + const anchoring = event.target.value as Anchoring; + setNewAnchoring(anchoring); + }} + > + + + +
+
+

+ This purchase becomes its first charge. Anchored to the last + charge, its cycle restarts from today rather than from a fixed + grid. +

+
+ )} + diff --git a/src/ui/recurringForm.test.ts b/src/ui/recurringForm.test.ts new file mode 100644 index 0000000..be4a05e --- /dev/null +++ b/src/ui/recurringForm.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { 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"; + +function withCosts(): Dataset { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + actions.addRecurringCost(d, { + name: "Rent", + archived: false, + amount: { amount: 8200, currency: "DKK" }, + startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + actions.addRecurringCost(d, { + name: "Phone", + archived: false, + amount: { amount: 300, currency: "DKK" }, + startDate: "2026-09-01", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + return d; +} + +describe("offerableCosts", () => { + test("an anchored cost is offerable on any date", () => { + const names = offerableCosts(withCosts(), "2027-05-11").map((c) => c.name); + expect(names).toContain("Phone"); + }); + + test("a calendar cost is offerable only when its grid has a slot in that month", () => { + const d = withCosts(); + expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).toContain("Rent"); + // Before the cost started, its grid has no slot to settle. + expect(offerableCosts(d, "2026-07-20").map((c) => c.name)).not.toContain("Rent"); + }); + + test("an archived cost is not offered", () => { + const d = withCosts(); + actions.endRecurringCost(d, d.recurring[0]!.id, "2026-09"); + expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).not.toContain("Rent"); + }); +}); + +describe("sourceFor", () => { + test("an anchored cost's source carries no slot", () => { + const d = withCosts(); + const phone = d.recurring.find((c) => c.name === "Phone")!; + expect(sourceFor(d, phone, "2026-09-20")).toEqual({ recurringId: phone.id }); + }); + + test("a calendar cost's source names the slot in the purchase's own month", () => { + const d = withCosts(); + const rent = d.recurring.find((c) => c.name === "Rent")!; + expect(sourceFor(d, rent, "2026-10-03")).toEqual({ + recurringId: rent.id, + occurrenceDate: "2026-10", + }); + }); + + test("throws when the cost has no occurrence in the purchase's month", () => { + const d = withCosts(); + const rent = d.recurring.find((c) => c.name === "Rent")!; + // Before the cost started: its grid has no slot in July. + expect(() => sourceFor(d, rent, "2026-07-20")).toThrow(); + }); + + // CONTROLLER RULING: everyNDays + calendar is a legal combination whose + // slots are specific DAYS, not months. Deriving the slot from + // monthOf(purchaseDate) alone would name a slot ("2026-09") that does not + // exist on this grid — the real slot is a day like "2026-09-01". Resolving + // by lookup (via occurrencesOf) gets the real slot; deriving it would not. + test("a day-granular calendar cost's source names the REAL slot date, not the bare month", () => { + const d = createSeedDataset("2026-09"); + const post = d.posts[0]!; + const cost = actions.addRecurringCost(d, { + name: "Groceries plan", + archived: false, + amount: { amount: 500, currency: "DKK" }, + startDate: "2026-09-01", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); + // Occurrences from 2026-09-01 stepping by 30 days: 09-01, then 10-01 — + // only one slot, 2026-09-01, falls in September. + expect(sourceFor(d, cost, "2026-09-20")).toEqual({ + recurringId: cost.id, + occurrenceDate: "2026-09-01", + }); + }); +}); + +describe("seedCostFromDraft", () => { + test("carries the purchase's amount, currency, splits and date", () => { + const draft = { + date: "2026-09-20", + description: "Gym", + note: "", + amount: 349, + currency: "DKK" as const, + splitMode: "percent" as const, + splits: [{ postId: "p1", value: 100, absorbsRemainder: true }], + plan: null, + }; + const seeded = seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar"); + expect(seeded.name).toBe("Gym"); + expect(seeded.amount).toEqual({ amount: 349, currency: "DKK" }); + expect(seeded.splits).toEqual([{ postId: "p1", value: 100, absorbsRemainder: true }]); + expect(seeded.anchoring).toBe("calendar"); + }); + + test("a calendar monthly cost starts in the purchase's MONTH", () => { + const draft = { + date: "2026-09-20", + description: "Rent", + note: "", + amount: 8200, + currency: "DKK" as const, + splitMode: "percent" as const, + splits: [{ postId: "p1", value: 100, absorbsRemainder: true }], + plan: null, + }; + expect( + seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar").startDate, + ).toBe("2026-09"); + }); + + test("an anchored monthly cost starts on the purchase's DAY", () => { + // Day precision: the cycle runs from the charge, so it needs a real day. + const draft = { + date: "2026-09-20", + description: "Phone", + note: "", + amount: 300, + currency: "DKK" as const, + splitMode: "percent" as const, + splits: [{ postId: "p1", value: 100, absorbsRemainder: true }], + plan: null, + }; + expect( + seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "lastCharge").startDate, + ).toBe("2026-09-20"); + }); +}); diff --git a/src/ui/recurringForm.ts b/src/ui/recurringForm.ts new file mode 100644 index 0000000..355c68c --- /dev/null +++ b/src/ui/recurringForm.ts @@ -0,0 +1,98 @@ +import { monthOf } from "../domain/months.ts"; +import { occurrencesOf } from "../domain/occurrences.ts"; +import type { + Anchoring, + Dataset, + IsoDate, + Purchase, + Recurrence, + RecurringCost, +} from "../domain/types.ts"; +import type { PurchaseDraft } from "./purchaseForm.ts"; + +/** + * The costs a purchase dated `date` could be a charge for. + * + * An anchored (`lastCharge`) cost is always offerable: it has no grid of + * slots, so any date can be the moment its cycle restarted — that IS the + * refresh. A `calendar` cost is offerable only where its grid actually has a + * slot in that purchase's month; tagging one outside its grid would record a + * charge settling nothing. + */ +export function offerableCosts(dataset: Dataset, date: IsoDate): RecurringCost[] { + const month = monthOf(date); + return dataset.recurring + .filter((cost) => !cost.archived) + .filter((cost) => { + if (cost.anchoring === "lastCharge") return true; + return occurrencesOf(cost, dataset.purchases, month).some( + (occurrence) => monthOf(occurrence.date) === month, + ); + }) + .sort((a, b) => a.order - b.order); +} + +/** + * The `source` a purchase gets when tagged to a cost. + * + * An anchored cost has no slots, so the purchase's own date is the whole + * story — the charge itself is what rebases the series. + * + * A calendar cost's charge settles a specific slot, and that slot is NOT + * always `{ recurringId, occurrenceDate: monthOf(purchaseDate) }`: that + * shape is only correct for a month-granular grid (`everyNMonths` under + * `calendar`). `everyNDays` + `calendar` is a legal combination too, and its + * slots are specific DAYS — deriving the slot from the month alone would + * name a slot that does not exist on that grid. + * + * So the slot is resolved by LOOKUP instead of derivation: walk the cost's + * occurrences through the purchase's month and take whichever one actually + * falls there. That is correct for both a month-granular grid and a + * day-granular calendar cost. If nothing falls in that month, the cost + * should never have been offered — throw rather than invent a slot. + */ +export function sourceFor( + dataset: Dataset, + cost: RecurringCost, + purchaseDate: IsoDate, +): Purchase["source"] { + if (cost.anchoring === "lastCharge") return { recurringId: cost.id }; + + const month = monthOf(purchaseDate); + const occurrence = occurrencesOf(cost, dataset.purchases, month).find( + (candidate) => monthOf(candidate.date) === month, + ); + if (!occurrence) { + throw new Error( + `"${cost.name}" has no occurrence in ${month}, so a purchase dated ${purchaseDate} cannot ` + + `be a charge for it.`, + ); + } + return { recurringId: cost.id, occurrenceDate: occurrence.date }; +} + +/** + * A new cost seeded from the purchase being typed. + * + * A calendar monthly cost is month-granular; every other combination needs a + * real day — an anchored monthly cost's next due date is computed + * day-precisely from the charge, and the two day-granular recurrence kinds + * hand their `startDate` straight to `addDays` regardless of anchoring. + */ +export function seedCostFromDraft( + draft: PurchaseDraft, + recurrence: Recurrence, + anchoring: Anchoring, +): Omit { + const monthGranular = recurrence.kind === "everyNMonths" && anchoring === "calendar"; + return { + name: draft.description, + archived: false, + amount: { amount: draft.amount, currency: draft.currency }, + startDate: monthGranular ? monthOf(draft.date) : draft.date, + recurrence, + anchoring, + splitMode: draft.splitMode, + splits: draft.splits.map((s) => ({ ...s })), + }; +} diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index e354b7d..a833bf5 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -21,7 +21,8 @@ import { monthOf } from "../../../domain/months.ts"; import { toDayOrdinal } from "../../../domain/days.ts"; import type { IsoDate, Recurrence, RecurringCost, Split } from "../../../domain/types.ts"; -const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/** Also used by `PurchaseDialog`'s "+ New recurring cost…" control. */ +export const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; /** "YYYY-MM" is 7 characters; "YYYY-MM-DD" is 10. Same idiom PurchaseDialog uses. */ export function isDayGranular(date: IsoDate): boolean {