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 {