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.
This commit is contained in:
Lucas Winther committed 2026-09-01 20:53:05 +02:00
1 parent 5a11941d39
commit 5b879809d2
4 files changed
+235 -1

No files matched your search

+86
View File
@@ -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 (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange(withPlan(draft, monthOf(draft.date), 3))}
>
Spread this over several months
</Button>
);
}
return (
<fieldset className="space-y-3 rounded border p-3">
<div className="flex items-center justify-between">
<Label>Finance plan</Label>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">months</span>
<Input
type="number"
min={1}
className="h-8 w-16 tabular-nums"
value={draft.plan.slices.length}
onChange={(event) => {
const months = Number(event.target.value);
if (months >= 1) {
onChange(withPlan(draft, draft.plan!.startMonth, months));
}
}}
/>
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(withoutPlan(draft))}>
remove plan
</Button>
</div>
</div>
<div className="space-y-1">
{draft.plan.slices.map((slice, index) => (
<div key={slice.month} className="flex items-center gap-2 text-sm">
<span className="w-20 text-muted-foreground">{slice.month}</span>
<Input
type="number"
step="0.01"
className="h-8 w-32 font-money"
value={slice.amount.amount}
onChange={(event) =>
onChange(setSliceAmount(draft, index, Number(event.target.value) || 0))
}
/>
<span className="text-xs text-muted-foreground">{draft.currency}</span>
</div>
))}
</div>
<p className={`text-xs ${balance === 0 ? "text-muted-foreground" : ""}`}>
{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.`}
</p>
</fieldset>
);
}
+18 -1
View File
@@ -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) {
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
<PlanEditor draft={draft} onChange={setDraft} />
{purchase?.schedule && !purchase.schedule.cancelledFromMonth && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
mutate((data) => cancelScheduleFrom(data, purchase.id, monthId));
setOpen(false);
}}
>
Cancel remaining slices from {monthId}
</Button>
)}
{errors.length > 0 && (
<ul className="space-y-1 text-sm text-destructive">
{errors.map((message) => (
+85
View File
@@ -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);
}
+46
View File
@@ -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<Purchase, "id"> {
};
}
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,