feat: support unassigned purchases and allocation status
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s

Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
This commit is contained in:
Lucas Winther
2026-09-30 13:10:10 +02:00
parent c1446db39f
commit b0e8145d2e
31 changed files with 469 additions and 80 deletions
+48
View File
@@ -0,0 +1,48 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts";
import { PlanEditor } from "./PlanEditor.tsx";
test("a purchase dated by month can start a finance plan in that month", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 };
const render = () => root.render(<PlanEditor draft={draft} onChange={(next) => { draft = next; render(); }} />);
try {
act(render);
const button = container.querySelector("button")!;
expect(button.disabled).toBe(false);
act(() => button.click());
expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
test("an incomplete purchase date cannot start a finance plan", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<PlanEditor draft={{ ...emptyDraft("2026-09", "food"), date: "" }} onChange={() => {}} />));
expect(container.querySelector("button")!.disabled).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+7 -9
View File
@@ -17,15 +17,13 @@ interface Props {
onChange: (next: PurchaseDraft) => void;
}
// A native <input type="date"> reports "" until the user has filled in every
// field, and monthOf() throws on anything that isn't a well-formed
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
// domain is right to throw on malformed input; the caller just must not call
// it with malformed input.
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function hasValidDate(draft: PurchaseDraft): boolean {
return ISO_DATE_PATTERN.test(draft.date);
try {
monthOf(draft.date);
return true;
} catch {
return false;
}
}
export function PlanEditor({ draft, onChange }: Props) {
@@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) {
</Button>
{!dateReady && (
<p className="text-xs text-muted-foreground">
Pick a date for this purchase first.
Pick a month or date for this purchase first.
</p>
)}
</div>
+43
View File
@@ -0,0 +1,43 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTable } from "./PostTable.tsx";
test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => {
const data = createSeedDataset("2026-09");
const [unbudgeted, zero] = data.posts;
unbudgeted!.name = "Unbudgeted";
unbudgeted!.rules = [];
zero!.name = "Zero rule";
zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }];
data.purchases = [unbudgeted!, zero!].map((post, i) => ({
id: `p${i}`, date: "2026-09", description: post.name,
total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const,
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
}));
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><PostTable monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!;
const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!;
expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0");
expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+5 -5
View File
@@ -50,21 +50,21 @@ interface RowCells {
* plain function, not a component, and both table markups call it from inside
* a map.
*/
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells {
return {
carriedIn: fmt.signedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: "",
allocation: fmt.amount(figures.allocation),
allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—",
charges: fmt.amount(figures.charges),
remaining: fmt.signedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "",
projected: fmt.signedAmount(figures.projected),
segments: carryMeterSegments(figures),
segments: carryMeterSegments(figures, hasAllocationRule),
};
}
+18 -2
View File
@@ -49,6 +49,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [detachBill, setDetachBill] = useState(false);
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -68,6 +69,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
function reset() {
setDetachBill(false);
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -86,7 +88,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
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 if (purchase) updatePurchase(data, purchase.id, {
...toPurchase(draft),
...(detachBill ? { source: undefined } : {}),
});
else addPurchase(data, toPurchase(draft));
}).then((saved) => {
if (!saved) return;
@@ -166,7 +171,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk((b) => ({ ...b, postId }));
}}
>
<option value="">Choose a post…</option>
<option value="">No post (unassigned)</option>
{activePosts.map((post) => (
<option key={post.id} value={post.id}>
{post.name}
@@ -301,6 +306,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
{purchase?.source && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={detachBill}
onChange={(event) => setDetachBill(event.target.checked)}
/>
<span>Unlink from bill. The occurrence will show as expected again.</span>
</label>
)}
<PlanEditor draft={draft} onChange={setDraft} />
</>
)}
+2 -2
View File
@@ -53,8 +53,8 @@ export function RuleHistory({ post }: Props) {
<div className="space-y-3 rounded-md border border-budget-rule p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
spending still records, and shows as overspend.
Not budgeted. Until a rule starts, this post is allocated nothing.
Spending still records without an overspend status.
</p>
) : (
<table className="w-full text-xs">
+22
View File
@@ -39,6 +39,26 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
return (
<fieldset className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.splits.length === 0}
onChange={(event) =>
onChange({
...draft,
splits: event.target.checked
? []
: posts[0]
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
: [],
})
}
disabled={posts.length === 0 && draft.splits.length === 0}
/>
No post — leave this purchase unassigned
</label>
{draft.splits.length > 0 && (
<>
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<Segmented
@@ -136,6 +156,8 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</>
)}
</fieldset>
);
}
+11 -2
View File
@@ -59,7 +59,7 @@ export function YearMatrix({ view, mode }: Props) {
<td
key={i}
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
mode === "balance" && value < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[i] && value < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(value)}
@@ -68,13 +68,22 @@ export function YearMatrix({ view, mode }: Props) {
})}
<td
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[11] && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
{mode === "spend" && view.totalLooseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2">Unassigned</td>
{view.looseByMonth.map((amount, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(amount)}</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(view.totalLooseCharges)}</td>
</tr>
)}
</tbody>
</table>
</div>