From 5b879809d21a648422b10e75f75b6dfff5bd8d97 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 20:53:05 +0200 Subject: [PATCH] feat(ui): add finance plan editor Plans default to an equal split, each slice is individually editable, and plans compose with splits. Cancelling keeps past slices. planBalance delegates to domain slicesBalance rather than recomputing the same total-minus-slices arithmetic, per the project's one-implementation rule for shared math. --- src/ui/components/PlanEditor.tsx | 86 ++++++++++++++++++++++++++++ src/ui/components/PurchaseDialog.tsx | 19 +++++- src/ui/purchaseForm.test.ts | 85 +++++++++++++++++++++++++++ src/ui/purchaseForm.ts | 46 +++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/ui/components/PlanEditor.tsx diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx new file mode 100644 index 0000000..9ef729d --- /dev/null +++ b/src/ui/components/PlanEditor.tsx @@ -0,0 +1,86 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { formatAmount } from "../format.ts"; +import { + planBalance, + setSliceAmount, + withPlan, + withoutPlan, + type PurchaseDraft, +} from "../purchaseForm.ts"; +import { monthOf } from "../../domain/months.ts"; + +interface Props { + draft: PurchaseDraft; + onChange: (next: PurchaseDraft) => void; +} + +export function PlanEditor({ draft, onChange }: Props) { + const balance = planBalance(draft); + + if (!draft.plan) { + return ( + + ); + } + + return ( +
+
+ +
+ months + { + const months = Number(event.target.value); + if (months >= 1) { + onChange(withPlan(draft, draft.plan!.startMonth, months)); + } + }} + /> + +
+
+ +
+ {draft.plan.slices.map((slice, index) => ( +
+ {slice.month} + + onChange(setSliceAmount(draft, index, Number(event.target.value) || 0)) + } + /> + {draft.currency} +
+ ))} +
+ +

+ {balance === 0 + ? `Slices total ${formatAmount(draft.amount)} ${draft.currency}, matching the purchase.` + : `Slices are ${formatAmount(Math.abs(balance))} ${draft.currency} ${ + balance > 0 ? "short of" : "over" + } the purchase total.`} +

+
+ ); +} diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index ad8e105..8456555 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SplitEditor } from "./SplitEditor.tsx"; +import { PlanEditor } from "./PlanEditor.tsx"; import { emptyDraft, fromPurchase, @@ -20,7 +21,7 @@ import { } from "../purchaseForm.ts"; import { useDataset } from "../hooks/useDataset.ts"; import { useMutate } from "../hooks/useMutate.ts"; -import { addPurchase, updatePurchase } from "../../store/actions.ts"; +import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts"; import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts"; interface Props { @@ -128,6 +129,22 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { + + + {purchase?.schedule && !purchase.schedule.cancelledFromMonth && ( + + )} + {errors.length > 0 && (
    {errors.map((message) => ( diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts index 9de8107..1053a60 100644 --- a/src/ui/purchaseForm.test.ts +++ b/src/ui/purchaseForm.test.ts @@ -5,6 +5,10 @@ import { validatePurchase, toPurchase, fromPurchase, + withPlan, + withoutPlan, + setSliceAmount, + planBalance, type PurchaseDraft, } from "./purchaseForm.ts"; @@ -114,3 +118,84 @@ describe("toPurchase and fromPurchase", () => { expect(toPurchase(draft()).schedule).toBeNull(); }); }); + +describe("finance plans", () => { + test("withPlan generates equal slices from the given month", () => { + const d = withPlan(draft({ amount: 3000 }), "2026-10", 6); + expect(d.plan!.slices).toHaveLength(6); + expect(d.plan!.slices[0]).toEqual({ + month: "2026-10", + amount: { amount: 500, currency: "DKK" }, + }); + expect(d.plan!.slices[5]!.month).toBe("2027-03"); + }); + + test("withPlan keeps the splits untouched, so plans and splits compose", () => { + const split = draft({ + amount: 3000, + splits: [ + { postId: "games", value: 70, absorbsRemainder: true }, + { postId: "events", value: 30, absorbsRemainder: false }, + ], + }); + expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits); + }); + + test("withoutPlan clears the schedule", () => { + expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull(); + }); + + test("setSliceAmount edits one slice and leaves the others alone", () => { + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]); + }); + + test("planBalance reports the difference between the slices and the total", () => { + const even = withPlan(draft({ amount: 3000 }), "2026-10", 3); + expect(planBalance(even)).toBe(0); + expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500); + }); + + test("planBalance is zero when there is no plan", () => { + expect(planBalance(draft())).toBe(0); + }); + + test("a plan with unbalanced slices is still valid", () => { + // A deposit-then-instalments plan may legitimately not sum to the total + // yet while the user is typing; the editor warns rather than blocking. + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + expect(validatePurchase(d)).toEqual([]); + }); + + test("a plan with zero months is rejected", () => { + expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i); + }); + + test("split×plan composition: each slice divides across posts in the split ratio", () => { + // Pins this task's headline behaviour: plans and splits compose. The + // plan editor only ever changes draft.plan; the ratio in draft.splits + // is applied per-slice by the store/domain layer, not here — this test + // just confirms withPlan never disturbs the splits that make that + // composition possible. + const composed = withPlan( + draft({ + amount: 3000, + splits: [ + { postId: "games", value: 70, absorbsRemainder: true }, + { postId: "events", value: 30, absorbsRemainder: false }, + ], + }), + "2026-10", + 3, + ); + expect(composed.splits).toEqual([ + { postId: "games", value: 70, absorbsRemainder: true }, + { postId: "events", value: 30, absorbsRemainder: false }, + ]); + expect(sliceSum(composed)).toBe(3000); + }); +}); + +function sliceSum(draft: PurchaseDraft): number { + return draft.plan!.slices.reduce((sum, s) => sum + s.amount.amount, 0); +} diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts index 9013f79..a7388e5 100644 --- a/src/ui/purchaseForm.ts +++ b/src/ui/purchaseForm.ts @@ -1,4 +1,5 @@ import { roundMoney } from "../domain/money.ts"; +import { equalSlices, slicesBalance } from "../domain/plans.ts"; import type { Currency, IsoDate, @@ -94,6 +95,51 @@ export function toPurchase(draft: PurchaseDraft): Omit { }; } +export function withPlan( + draft: PurchaseDraft, + startMonth: MonthId, + months: number, +): PurchaseDraft { + return { + ...draft, + plan: { + startMonth, + slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months), + }, + }; +} + +export function withoutPlan(draft: PurchaseDraft): PurchaseDraft { + return { ...draft, plan: null }; +} + +export function setSliceAmount( + draft: PurchaseDraft, + index: number, + amount: number, +): PurchaseDraft { + if (!draft.plan) return draft; + return { + ...draft, + plan: { + ...draft.plan, + slices: draft.plan.slices.map((slice, i) => + i === index ? { ...slice, amount: { ...slice.amount, amount } } : slice, + ), + }, + }; +} + +/** + * slices - total, delegated to the domain's slicesBalance so the "sum the + * slices and compare to the total" arithmetic has exactly one implementation. + * Positive means the slices fall short of the purchase total. + */ +export function planBalance(draft: PurchaseDraft): number { + if (!draft.plan) return 0; + return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices); +} + export function fromPurchase(purchase: Purchase): PurchaseDraft { return { date: purchase.date,