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" && void mutate(draft => removeBudgetFolder(draft, key.id))}>remove }
{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 }));
+ }}>Month Date }
{/* 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" }))}>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)} />
+ Every
+
+ setNewInterval(event.currentTarget.value)} />
+ {
+ const kind = event.target.value as Recurrence["kind"];
+ setNewUnit(kind);
+ setNewStartDate(date => normalizedStartDateFor(kind, date));
+ if (kind !== "everyNMonths") setNewDatePrecision("date");
+ }}>
+ months
+ weeks
+ days
+
+
+
+ {newUnit === "everyNWeeks" && setNewWeekday(Number(event.target.value))}>
+ {WEEKDAYS.map((day, index) => {day} )}
+ }
+ First due
+
+ {newUnit === "everyNMonths" && {
+ 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);
+ });
+ }}>Month Date }
+ setNewStartDate(event.target.value)} aria-invalid={!validNewStart} />
+
+
+ setNewAnchoring(event.target.value as RecurringCost["anchoring"])}>
+ From calendar
+ From last charge
+
setFolderId(event.target.value || null)}>
Unfiled
{dataset.folders.map(folder => {folder.name} )}
setDestinationKind(event.target.value as "existing" | "dedicated")}>
- Use existing post
Create dedicated post
+ Use existing post
{destinationKind === "dedicated" ? (
- setNewPostName(event.target.value)} />
+ setNewPostName(event.target.value)} />
) : (
)}
{
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
+ 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.
);
}