From c1446db39f200442b9e5f2d219791eb96072f071 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 01:50:48 +0200 Subject: [PATCH] feat: improve recurring cost setup and folder management --- docs/TODO.md | 12 +++ src/store/actions.test.ts | 33 +++++++ src/store/actions.ts | 31 +++++- src/ui/routes/SummaryRoute.tsx | 4 +- .../settings/BudgetFoldersSection.test.tsx | 6 ++ .../routes/settings/BudgetFoldersSection.tsx | 3 +- .../routes/settings/RecurringSection.test.tsx | 40 +++++++- src/ui/routes/settings/RecurringSection.tsx | 94 ++++++++++++++++--- 8 files changed, 203 insertions(+), 20 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 41df9bc..45b9c58 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 244563f..36e185f 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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 () => { diff --git a/src/store/actions.ts b/src/store/actions.ts index 1d7be5e..430d129 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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; } /** diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx index 3e819ac..036ce9d 100644 --- a/src/ui/routes/SummaryRoute.tsx +++ b/src/ui/routes/SummaryRoute.tsx @@ -96,7 +96,7 @@ export function SummaryRoute() { ? view.byPost.map((entry) => ( {entry.post.name} - {fmt.amount(entry.charges)} + {fmt.money(entry.charges, base)} {view.totalCharges === 0 ? "—" @@ -111,7 +111,7 @@ export function SummaryRoute() { {entry.monthId} - {fmt.amount(entry.charges)} + {fmt.money(entry.charges, base)} {view.totalCharges === 0 ? "—" diff --git a/src/ui/routes/settings/BudgetFoldersSection.test.tsx b/src/ui/routes/settings/BudgetFoldersSection.test.tsx index 5f1cc66..17d0260 100644 --- a/src/ui/routes/settings/BudgetFoldersSection.test.tsx +++ b/src/ui/routes/settings/BudgetFoldersSection.test.tsx @@ -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); }); diff --git a/src/ui/routes/settings/BudgetFoldersSection.tsx b/src/ui/routes/settings/BudgetFoldersSection.tsx index cf1511b..b0c5750 100644 --- a/src/ui/routes/settings/BudgetFoldersSection.tsx +++ b/src/ui/routes/settings/BudgetFoldersSection.tsx @@ -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 + {key.kind === "folder" && } {key.kind === "folder" &&
{children(key.id)}
} diff --git a/src/ui/routes/settings/RecurringSection.test.tsx b/src/ui/routes/settings/RecurringSection.test.tsx index 3532edc..1b47163 100644 --- a/src/ui/routes/settings/RecurringSection.test.tsx +++ b/src/ui/routes/settings/RecurringSection.test.tsx @@ -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()); 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()); + 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, { diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index 45ea3b7..8e46cc5 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -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("everyNMonths"); + const [newWeekday, setNewWeekday] = useState(1); + const [newStartDate, setNewStartDate] = useState(currentMonth); + const [newDatePrecision, setNewDatePrecision] = useState<"month" | "date">("month"); + const [newAnchoring, setNewAnchoring] = useState("calendar"); const [folderId, setFolderId] = useState(null); const [newSelection, setNewSelection] = useState({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); const [editingId, setEditingId] = useState(null); + const [endDatePrecision, setEndDatePrecision] = useState>({}); const [editSelection, setEditSelection] = useState(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() { +
+ {cost.recurrence.kind === "everyNMonths" && { + const precision = event.target.value; + const startDate = precision === "date" ? `${monthOf(cost.startDate)}-01` : monthOf(cost.startDate); + void mutate(draft => updateRecurringCost(draft, cost.id, { startDate })); + }}>} {/* 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() { { @@ -288,8 +308,11 @@ export function RecurringSection() { ); }} /> +
+
+ setEndDatePrecision(precision => ({ ...precision, [cost.id]: event.target.value as "month" | "date" }))}> {/* 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. */} { const typed = event.target.value.trim(); if (!isValidEndedFrom(typed)) { @@ -318,6 +341,7 @@ export function RecurringSection() { ); }} /> +
@@ -378,30 +402,69 @@ export function RecurringSection() { value={newName} onInput={(event) => setNewName(event.currentTarget.value)} /> - setNewAmount(event.currentTarget.value)} /> + setNewAmount(event.currentTarget.value)} /> + + {newUnit === "everyNWeeks" && setNewWeekday(Number(event.target.value))}> + {WEEKDAYS.map((day, index) => )} + } + + setNewAnchoring(event.target.value as RecurringCost["anchoring"])}> + + + setFolderId(event.target.value || null)}> {dataset.folders.map(folder => )} setDestinationKind(event.target.value as "existing" | "dedicated")}> - + {destinationKind === "dedicated" ? ( - setNewPostName(event.target.value)} /> + setNewPostName(event.target.value)} /> ) : ( )}
+

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.

); }