feat: improve recurring cost setup and folder management
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 33s

This commit is contained in:
Lucas Winther committed 2026-09-30 01:50:48 +02:00
1 parent 804f32c5fb
commit c1446db39f
8 files changed
+203 -20

No files matched your search

+12
View File
@@ -31,6 +31,18 @@ IndexedDB instead of the app.
---
## Optional monthly income forecast
Add a user-controlled toggle that predicts a month's income from earlier months.
For each earlier month, divide its recorded income by that month's number of
workdays; average those per-workday figures, then multiply by the target month's
workdays. Let the owner adjust the prediction when income changes, for example
after a raise, without rewriting recorded income or earlier months. Show
predicted and entered income distinctly, and keep budgeting based on entered
income unless the owner explicitly chooses otherwise. Before implementation,
settle whether workdays mean Monday–Friday only or exclude local holidays, and
whether an adjustment applies to one month or all future predictions.
## 1. Folder rename blur during an in-flight save
A folder name draft can remain unsaved if the owner changes it while a rename
+33
View File
@@ -16,6 +16,22 @@ const billInput = (postId: string, folderId: string | null = null) => ({
});
describe("budget folder placement", () => {
test("removing a folder moves its direct contents to its parent in order", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
const before = actions.addPost(data, "Before", "DKK", parent.id);
const removed = actions.addBudgetFolder(data, "Removed", parent.id);
const after = actions.addPost(data, "After", "DKK", parent.id);
const child = actions.addBudgetFolder(data, "Child", removed.id);
const post = actions.addPost(data, "Kept", "DKK", removed.id);
const bill = actions.addRecurringCost(data, billInput(post.id, removed.id));
actions.removeBudgetFolder(data, removed.id);
expect(data.folders.some(folder => folder.id === removed.id)).toBe(false);
expect([child.parentId, post.folderId, bill.folderId]).toEqual([parent.id, parent.id, parent.id]);
expect(actions.budgetSiblings(data, parent.id).map(item => item.id)).toEqual([before.id, child.id, post.id, bill.id, after.id]);
expect(bill.splits[0]!.postId).toBe(post.id);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
});
test("mixed siblings reorder densely", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Utilities", null);
@@ -585,6 +601,23 @@ describe("recurring costs", () => {
expect(post.folderId).toBe(folder.id);
expect(bill.folderId).toBe(folder.id);
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
expect(post.rules).toEqual([{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 8000, currency: "DKK" } } }]);
actions.confirmOccurrence(data, bill.id, "2026-09");
const figures = monthView(data, "2026-09").rows.find(row => row.post.id === post.id)!.figures;
expect(figures.allocation).toBe(8000);
expect(figures.charges).toBe(8000);
expect(figures.remaining).toBe(0);
});
test("dedicated post allocates a rounded monthly share of a less frequent bill", () => {
const data = draft();
const bill = actions.addRecurringCostWithPost(data, {
name: "Insurance", folderId: null, archived: false,
amount: { amount: 1200, currency: "DKK" }, startDate: "2026-10",
recurrence: { kind: "everyNMonths", n: 12 }, anchoring: "calendar",
}, "Insurance");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.rules[0]).toEqual({ from: "2026-10", rule: { kind: "fixed", amount: { amount: 100, currency: "DKK" } } });
});
test("a rejected store mutation leaves neither dedicated record", async () => {
+30 -1
View File
@@ -109,6 +109,22 @@ export function renameBudgetFolder(draft: Dataset, id: FolderId, name: string):
folder.name = resolvedName;
}
/** Removes only the container; its direct children take its place in the parent. */
export function removeBudgetFolder(draft: Dataset, id: FolderId): void {
const folder = requireFolder(draft, id);
const parent = folder.parentId;
const siblings = siblingRefs(draft, parent);
const position = siblings.findIndex(entry => entry.key.kind === "folder" && entry.key.id === id);
const children = siblingRefs(draft, id);
for (const { key, item } of children) {
if (key.kind === "folder") (item as BudgetFolder).parentId = parent;
else (item as Post | RecurringCost).folderId = parent;
}
draft.folders.splice(draft.folders.indexOf(folder), 1);
siblings.splice(position, 1, ...children);
siblings.forEach(({ item }, order) => { item.order = order; });
}
export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void {
const item = requireBudgetItem(draft, key);
if (destination !== null) requireFolder(draft, destination);
@@ -602,12 +618,25 @@ export function addRecurringCostWithPost(
): RecurringCost {
const name = newPostName.trim();
if (!name) throw new Error("Post name cannot be blank");
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate);
const post = addPost(draft, name, cost.amount.currency, cost.folderId);
return addRecurringCost(draft, {
const bill = addRecurringCost(draft, {
...cost,
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
const monthly = recurrence.kind === "everyNMonths"
? bill.amount.amount / recurrence.n
: recurrence.kind === "everyNWeeks"
? bill.amount.amount * 52 / (12 * recurrence.n)
: bill.amount.amount * 365 / (12 * recurrence.n);
const factor = 10 ** draft.settings.digits;
const allocation = roundMoney(Math.ceil(monthly * factor) / factor, draft.settings.digits);
setRuleFrom(draft, post.id, monthOf(cost.startDate), {
kind: "fixed", amount: { amount: allocation, currency: cost.amount.currency },
});
return bill;
}
/**
+2 -2
View File
@@ -96,7 +96,7 @@ export function SummaryRoute() {
? view.byPost.map((entry) => (
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">{entry.post.name}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
@@ -111,7 +111,7 @@ export function SummaryRoute() {
{entry.monthId}
</Link>
</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
@@ -63,6 +63,12 @@ test("post and bill move independently", async () => setup(async (container, dat
const rows = Array.from(container.querySelectorAll("[data-budget-item]"));
expect(rows.findIndex(row => row.getAttribute("data-budget-item") === post.id)).toBeLessThan(rows.findIndex(row => row.getAttribute("data-budget-item") === utilities.id));
}));
test("remove folder moves nested items into its parent", async () => setup(async (container, data) => {
const housing = data.folders[0]!, utilities = data.folders[1]!;
await act(async () => { (container.querySelector('button[aria-label="Remove folder Utilities"]') as HTMLButtonElement).click(); });
expect(store.get().folders.some(folder => folder.id === utilities.id)).toBe(false);
expect(store.get().folders.find(folder => folder.id === housing.id)).toBeDefined();
}));
test("failed move reports error", async () => setup(async (container, data) => {
const before = JSON.stringify(data);
await act(async () => { select(container, `Folder for ${data.posts[0]!.name}`, data.folders[0]!.id); });
@@ -5,7 +5,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Section } from "../../components/Section.tsx";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addBudgetFolder, budgetSiblings, placeBudgetItem, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts";
import { addBudgetFolder, budgetSiblings, placeBudgetItem, removeBudgetFolder, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts";
export function BudgetFoldersSection() {
const dataset = useDataset();
@@ -79,6 +79,7 @@ export function BudgetFoldersSection() {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position + 1);
})}>down</Button>
{key.kind === "folder" && <Button size="xs" variant="ghost" aria-label={`Remove folder ${item.name}`} title="Move contents to parent and remove folder" onClick={() => void mutate(draft => removeBudgetFolder(draft, key.id))}>remove</Button>}
</div>
</div>
{key.kind === "folder" && <div className="ml-3 border-l border-budget-rule pl-3 sm:ml-5 sm:pl-5">{children(key.id)}</div>}
@@ -10,7 +10,7 @@ import { store } from "../../../store/index.ts";
import { addRecurringCost } from "../../../store/actions.ts";
import { RecurringSection } from "./RecurringSection.tsx";
test("bill requires an explicit destination", async () => {
test("existing post destination requires a selection", async () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
@@ -24,6 +24,8 @@ test("bill requires an explicit destination", async () => {
act(() => root.render(<RecurringSection />));
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { name.focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Phone", inputType: "insertText" })); });
const kind = container.querySelector('select[aria-label="Destination type"]') as HTMLSelectElement;
act(() => { kind.value = "existing"; kind.dispatchEvent(new Event("change", { bubbles: true })); });
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
expect(add.disabled).toBe(true);
const chosen = data.posts[1]!;
@@ -43,6 +45,42 @@ test("bill requires an explicit destination", async () => {
}
});
test("new bill defaults to a same-name post and accepts schedule controls", async () => {
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
expect((container.querySelector('select[aria-label="Destination type"]') as HTMLSelectElement).value).toBe("dedicated");
expect((container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement).value).toContain(data.settings.baseCurrency);
expect((container.querySelector('input[aria-label="New bill start date"]') as HTMLInputElement).type).toBe("month");
const precision = container.querySelector('select[aria-label="New bill date precision"]') as HTMLSelectElement;
act(() => { precision.value = "date"; precision.dispatchEvent(new Event("change", { bubbles: true })); });
expect((container.querySelector('input[aria-label="New bill start date"]') as HTMLInputElement).type).toBe("date");
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Insurance"); name.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "1200 DKK"); amount.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const interval = container.querySelector('input[aria-label="New bill interval"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(interval, "12"); interval.dispatchEvent(new InputEvent("input", { bubbles: true })); });
await act(async () => { Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!.click(); });
const bill = data.recurring[0]!;
const post = data.posts.find(post => post.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Insurance");
expect(bill.recurrence).toEqual({ kind: "everyNMonths", n: 12 });
expect(bill.startDate).toHaveLength(10);
expect(post.rules[0]!.rule).toEqual({ kind: "fixed", amount: { amount: 100, currency: "DKK" } });
} finally {
act(() => root.unmount()); container.remove();
Object.assign(store, original);
}
});
test("failed destination edit keeps its editor and error visible", async () => {
const data = createSeedDataset("2026-09");
const cost = addRecurringCost(data, {
+79 -15
View File
@@ -94,22 +94,36 @@ export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoD
return `${startDate}-01`;
}
function dateForInput(value: IsoDate | undefined, precision: "month" | "date"): string {
if (!value) return "";
return precision === "date" ? (isDayGranular(value) ? value : `${value}-01`) : value.slice(0, 7);
}
export function RecurringSection() {
const dataset = useDataset();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [newAmount, setNewAmount] = useState("0");
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("existing");
const [newAmount, setNewAmount] = useState(`0 ${dataset.settings.baseCurrency}`);
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("dedicated");
const [newPostName, setNewPostName] = useState("");
const [newInterval, setNewInterval] = useState("1");
const [newUnit, setNewUnit] = useState<Recurrence["kind"]>("everyNMonths");
const [newWeekday, setNewWeekday] = useState(1);
const [newStartDate, setNewStartDate] = useState<IsoDate>(currentMonth);
const [newDatePrecision, setNewDatePrecision] = useState<"month" | "date">("month");
const [newAnchoring, setNewAnchoring] = useState<RecurringCost["anchoring"]>("calendar");
const [folderId, setFolderId] = useState<string | null>(null);
const [newSelection, setNewSelection] = useState<RecurringSplitSelection>({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] });
const [editingId, setEditingId] = useState<string | null>(null);
const [endDatePrecision, setEndDatePrecision] = useState<Record<string, "month" | "date">>({});
const [editSelection, setEditSelection] = useState<RecurringSplitSelection | null>(null);
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
const activePosts = dataset.posts.filter(post => !post.archived).sort((a, b) => a.order - b.order);
const parsedNewAmount = parseMoneyInput(newAmount, dataset.currencies, base);
const validNewSplits = newSelection.splits.length > 0 && newSelection.splits.every(split => activePosts.some(post => post.id === split.postId));
const validNewInterval = Number.isInteger(Number(newInterval)) && Number(newInterval) >= 1;
const validNewStart = isValidStartDate(newStartDate, newUnit);
function amountCell(cost: RecurringCost) {
return (
@@ -265,6 +279,12 @@ export function RecurringSection() {
</NativeSelect>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
{cost.recurrence.kind === "everyNMonths" && <NativeSelect className="h-8 w-auto text-xs" aria-label={`Date precision for ${cost.name}`} value={isDayGranular(cost.startDate) ? "date" : "month"} onChange={event => {
const precision = event.target.value;
const startDate = precision === "date" ? `${monthOf(cost.startDate)}-01` : monthOf(cost.startDate);
void mutate(draft => updateRecurringCost(draft, cost.id, { startDate }));
}}><option value="month">Month</option><option value="date">Date</option></NativeSelect>}
{/* Uncontrolled: committing only happens on blur, and only
when the text parses as an IsoDate of the granularity
this cost's recurrence kind requires. Keyed on the
@@ -274,7 +294,7 @@ export function RecurringSection() {
<Input
key={`${cost.id}-${cost.startDate}`}
className="h-8 w-32"
type="text"
type={cost.recurrence.kind === "everyNMonths" && !isDayGranular(cost.startDate) ? "month" : "date"}
aria-label={`Start date for ${cost.name}`}
defaultValue={cost.startDate}
onBlur={(event) => {
@@ -288,8 +308,11 @@ export function RecurringSection() {
);
}}
/>
</div>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<NativeSelect className="h-8 w-auto text-xs" aria-label={`End date precision for ${cost.name}`} value={endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month")} onChange={event => setEndDatePrecision(precision => ({ ...precision, [cost.id]: event.target.value as "month" | "date" }))}><option value="month">Month</option><option value="date">Date</option></NativeSelect>
{/* Same idiom as "Starts": uncontrolled, committed on
blur, keyed on the stored value so an external change
(the end/restart button, or a rejected edit) shows up
@@ -297,12 +320,12 @@ export function RecurringSection() {
ended" and is itself a valid value — clearing it here
does the same thing as the "restart" button. */}
<Input
key={`${cost.id}-ends-${cost.endedFrom ?? ""}`}
key={`${cost.id}-ends-${cost.endedFrom ?? ""}-${endDatePrecision[cost.id] ?? "stored"}`}
className="h-8 w-32"
type="text"
type={endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month")}
placeholder="—"
aria-label={`Ends date for ${cost.name}`}
defaultValue={cost.endedFrom ?? ""}
defaultValue={dateForInput(cost.endedFrom, endDatePrecision[cost.id] ?? (cost.endedFrom && isDayGranular(cost.endedFrom) ? "date" : "month"))}
onBlur={(event) => {
const typed = event.target.value.trim();
if (!isValidEndedFrom(typed)) {
@@ -318,6 +341,7 @@ export function RecurringSection() {
);
}}
/>
</div>
</td>
<td className="py-2">
<div className="flex items-center justify-end gap-1">
@@ -378,30 +402,69 @@ export function RecurringSection() {
value={newName}
onInput={(event) => setNewName(event.currentTarget.value)}
/>
<Input className="w-28" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
<Input className="w-32" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
<label className="text-xs">Every
<span className="mt-1 flex items-center gap-1">
<Input className="w-20" type="number" min={1} step={1} aria-label="New bill interval" value={newInterval} onInput={event => setNewInterval(event.currentTarget.value)} />
<NativeSelect aria-label="New bill recurrence unit" value={newUnit} onChange={event => {
const kind = event.target.value as Recurrence["kind"];
setNewUnit(kind);
setNewStartDate(date => normalizedStartDateFor(kind, date));
if (kind !== "everyNMonths") setNewDatePrecision("date");
}}>
<option value="everyNMonths">months</option>
<option value="everyNWeeks">weeks</option>
<option value="everyNDays">days</option>
</NativeSelect>
</span>
</label>
{newUnit === "everyNWeeks" && <NativeSelect aria-label="New bill weekday" value={newWeekday} onChange={event => setNewWeekday(Number(event.target.value))}>
{WEEKDAYS.map((day, index) => <option key={day} value={index}>{day}</option>)}
</NativeSelect>}
<label className="text-xs">First due
<span className="mt-1 flex items-center gap-1">
{newUnit === "everyNMonths" && <NativeSelect aria-label="New bill date precision" value={newDatePrecision} onChange={event => {
const precision = event.target.value as "month" | "date";
setNewDatePrecision(precision);
setNewStartDate(date => {
const valid = isValidStartDate(date, "everyNMonths") ? date : currentMonth;
return precision === "date" ? normalizedStartDateFor("everyNDays", valid) : monthOf(valid);
});
}}><option value="month">Month</option><option value="date">Date</option></NativeSelect>}
<Input className="w-36" type={newUnit === "everyNMonths" && newDatePrecision === "month" ? "month" : "date"} aria-label="New bill start date" value={newStartDate} onChange={event => setNewStartDate(event.target.value)} aria-invalid={!validNewStart} />
</span>
</label>
<NativeSelect aria-label="New bill anchoring" value={newAnchoring} onChange={event => setNewAnchoring(event.target.value as RecurringCost["anchoring"])}>
<option value="calendar">From calendar</option>
<option value="lastCharge">From last charge</option>
</NativeSelect>
<NativeSelect aria-label="Bill folder" value={folderId ?? ""} onChange={event => setFolderId(event.target.value || null)}>
<option value="">Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</NativeSelect>
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
<option value="existing">Use existing post</option>
<option value="dedicated">Create dedicated post</option>
<option value="existing">Use existing post</option>
</NativeSelect>
{destinationKind === "dedicated" ? (
<Input aria-label="Dedicated post name" placeholder="New post name" value={newPostName} onChange={event => setNewPostName(event.target.value)} />
<Input aria-label="Dedicated post name" placeholder={newName.trim() || "Same name as bill"} value={newPostName} onChange={event => setNewPostName(event.target.value)} />
) : (
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
)}
<Button
variant="outline"
disabled={newName.trim() === "" || !parsedNewAmount || (destinationKind === "dedicated" ? !newPostName.trim() : !validNewSplits)}
disabled={newName.trim() === "" || !parsedNewAmount || !validNewInterval || !validNewStart || (destinationKind === "existing" && !validNewSplits)}
onClick={() => {
const name = newName.trim();
const amount = parsedNewAmount;
if (!amount) return;
const selectedFolder = folderId;
const selectedKind = destinationKind;
const dedicatedName = newPostName.trim();
const dedicatedName = newPostName.trim() || name;
const startDate = newStartDate;
const n = Number(newInterval);
const recurrence: Recurrence = newUnit === "everyNWeeks" ? { kind: newUnit, n, weekday: newWeekday } : { kind: newUnit, n };
const anchoring = newAnchoring;
const selectedSplits = newSelection.splits.map(split => ({ ...split }));
const selectedMode = newSelection.splitMode;
void mutate((draft) => {
@@ -410,21 +473,22 @@ export function RecurringSection() {
folderId: selectedFolder,
archived: false,
amount,
startDate: currentMonth,
recurrence: { kind: "everyNMonths" as const, n: 1 },
anchoring: "calendar" as const,
startDate,
recurrence,
anchoring,
};
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
else addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
},
).then((saved) => {
if (saved) { setNewName(""); setNewPostName(""); setNewAmount("0"); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
if (saved) { setNewName(""); setNewPostName(""); setNewAmount(`0 ${base}`); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
});
}}
>
Add recurring cost
</Button>
</div>
<p className="mt-2 text-xs text-budget-ink-muted">A dedicated post receives an automatic monthly allocation based on this bill’s schedule. If its first payment comes before enough has accumulated, its balance may be negative until later allocations catch up.</p>
</Section>
);
}