feat: organize posts and bills in settings

This commit is contained in:
Lucas Winther
2026-09-30 00:35:45 +02:00
parent 6de115843b
commit 725c836788
5 changed files with 157 additions and 31 deletions
+2
View File
@@ -1,4 +1,5 @@
import { CurrencySection } from "./settings/CurrencySection.tsx";
import { BudgetFoldersSection } from "./settings/BudgetFoldersSection.tsx";
import { PostsSection } from "./settings/PostsSection.tsx";
import { RecurringSection } from "./settings/RecurringSection.tsx";
import { DataSection } from "./settings/DataSection.tsx";
@@ -21,6 +22,7 @@ export function SettingsRoute() {
<div className="space-y-5">
<h1 className="text-2xl">Settings</h1>
<CurrencySection />
<BudgetFoldersSection />
<PostsSection />
<RecurringSection />
<DataSection />
@@ -0,0 +1,69 @@
import { expect, test } from "bun:test";
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 { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { addBudgetFolder, addRecurringCost } from "../../../store/actions.ts";
import { BudgetFoldersSection } from "./BudgetFoldersSection.tsx";
async function setup(run: (container: HTMLDivElement, data: ReturnType<typeof createSeedDataset>) => Promise<void>, fail = false) {
let data = createSeedDataset("2026-09");
const housing = addBudgetFolder(data, "Housing", null);
addBudgetFolder(data, "Utilities", housing.id);
addRecurringCost(data, { name: "Phone", folderId: null, archived: false, amount: { amount: 30, currency: "USD" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] });
data.fxRates = [];
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
const listeners = new Set<() => void>();
store.get = () => data;
store.subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn); };
store.mutate = async change => { if (fail) throw new Error("Disk full"); const draft = structuredClone(data); change(draft); data = draft; listeners.forEach(fn => fn()); };
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try { act(() => root.render(<BudgetFoldersSection />)); await run(container, data); }
finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); }
}
function select(container: HTMLDivElement, label: string, value: string) {
const field = container.querySelector(`select[aria-label="${label}"]`) as HTMLSelectElement;
field.value = value; field.dispatchEvent(new Event("change", { bubbles: true }));
}
function input(container: HTMLDivElement, label: string, value: string) {
const field = container.querySelector(`input[aria-label="${label}"]`) as HTMLInputElement;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(field, value);
field.dispatchEvent(new InputEvent("input", { bubbles: true }));
}
test("nested folder can be created and renamed", async () => setup(async (container, data) => {
const housing = data.folders[0]!;
const parent = container.querySelector('select[aria-label="Folder for Housing"]')!;
expect(parent.querySelector(`option[value="${housing.id}"]`)).toBeNull();
expect(parent.querySelector(`option[value="${data.folders[1]!.id}"]`)).toBeNull();
act(() => { input(container, "New folder name", "Services"); select(container, "New folder parent", housing.id); });
await act(async () => { Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Add folder")!.click(); });
const services = store.get().folders.find(f => f.name === "Services")!;
expect(services.parentId).toBe(housing.id);
await act(async () => { input(container, "Name for Services", "Household"); });
expect(store.get().folders.find(f => f.id === services.id)!.name).toBe("Household");
await act(async () => { select(container, "Folder for Utilities", ""); });
expect(store.get().folders[1]!.parentId).toBeNull();
}));
test("post and bill move independently", async () => setup(async (container, data) => {
const housing = data.folders[0]!, utilities = data.folders[1]!;
const post = data.posts[0]!, bill = data.recurring[0]!;
await act(async () => { select(container, `Folder for ${post.name}`, housing.id); });
await act(async () => { select(container, "Folder for Phone", utilities.id); });
expect(store.get().posts[0]!.folderId).toBe(housing.id);
expect(store.get().recurring[0]!.folderId).toBe(utilities.id);
expect(store.get().recurring[0]!.splits).toEqual(bill.splits);
await act(async () => { (container.querySelector(`button[aria-label="Move ${post.name} up"]`) as HTMLButtonElement).click(); });
expect(store.get().posts[0]!.order).toBe(0);
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("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); });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(JSON.stringify(store.get())).toBe(before);
}, true));
@@ -0,0 +1,81 @@
import { useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
export function BudgetFoldersSection() {
const dataset = useDataset();
const { mutate, error } = useMutate();
const [name, setName] = useState("");
const [parent, setParent] = useState<string | null>(null);
function folderPath(id: string): string {
const folder = dataset.folders.find(folder => folder.id === id)!;
return folder.parentId === null ? folder.name : `${folderPath(folder.parentId)} / ${folder.name}`;
}
const folders = [...dataset.folders].sort((a, b) => folderPath(a.id).localeCompare(folderPath(b.id)));
function allowedDestination(key: BudgetItemKey, id: string): boolean {
if (key.kind !== "folder") return true;
let cursor: string | null = id;
while (cursor !== null) {
if (cursor === key.id) return false;
cursor = dataset.folders.find(folder => folder.id === cursor)!.parentId;
}
return true;
}
function children(parentId: string | null): ReactNode {
const siblings = budgetSiblings(dataset, parentId);
return <ul className="space-y-2">{siblings.map((key, index) => {
const item = key.kind === "folder" ? dataset.folders.find(item => item.id === key.id)! : key.kind === "post" ? dataset.posts.find(item => item.id === key.id)! : dataset.recurring.find(item => item.id === key.id)!;
return <li key={`${key.kind}-${key.id}`}>
<div data-budget-item={key.id} className="flex flex-wrap items-center gap-2 border-b border-budget-rule py-2">
<div className="min-w-0 flex-1 basis-40">
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${item.name}`} value={item.name} onInput={event => {
const name = event.currentTarget.value;
if (name.trim()) void mutate(draft => renameBudgetFolder(draft, key.id, name));
}} /> : <span className="break-words text-sm">{item.name}</span>}
<span className="mt-1 block text-xs text-budget-ink-muted">{key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"}</span>
</div>
<NativeSelect className="h-8 w-full sm:w-56 text-xs md:text-xs" aria-label={`Folder for ${item.name}`} value={parentId ?? ""} onChange={event => {
const destination = event.target.value || null;
void mutate(draft => placeBudgetItem(draft, key, destination, budgetSiblings(draft, destination).filter(sibling => !(sibling.kind === key.kind && sibling.id === key.id)).length));
}}>
<option value="">Unfiled</option>
{folders.filter(folder => allowedDestination(key, folder.id)).map(folder => <option key={folder.id} value={folder.id}>{folderPath(folder.id)}</option>)}
</NativeSelect>
<div className="flex gap-1">
<Button size="xs" variant="ghost" aria-label={`Move ${item.name} up`} disabled={index === 0} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position - 1);
})}>up</Button>
<Button size="xs" variant="ghost" aria-label={`Move ${item.name} down`} disabled={index === siblings.length - 1} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position + 1);
})}>down</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>}
</li>;
})}</ul>;
}
return <div id="budget-folders" className="scroll-mt-5"><Section title="Budget folders" hint="Organize folders, posts, and bills together. Moving a bill keeps its destination posts and all payment history.">
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<h3 className="text-sm font-medium">Top level · Unfiled</h3>
{children(null)}
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
<label className="text-xs">Folder name<Input className="mt-1 w-48" aria-label="New folder name" value={name} onInput={event => setName(event.currentTarget.value)} /></label>
<label className="text-xs">Parent folder<NativeSelect className="mt-1 w-56" aria-label="New folder parent" value={parent ?? ""} onChange={event => setParent(event.target.value || null)}>
<option value="">Unfiled</option>{folders.map(folder => <option key={folder.id} value={folder.id}>{folderPath(folder.id)}</option>)}
</NativeSelect></label>
<Button variant="outline" disabled={!name.trim()} onClick={() => {
const newName = name.trim(), destination = parent;
void mutate(draft => { addBudgetFolder(draft, newName, destination); }).then(saved => { if (saved) setName(""); });
}}>Add folder</Button>
</div>
</Section></div>;
}
+3 -13
View File
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
import { addPost, setPostArchived, updatePost } from "../../../store/actions.ts";
import type { Currency, Post } from "../../../domain/types.ts";
import { ruleAt } from "../../../domain/allocation.ts";
import { currentMonth } from "../../../store/index.ts";
@@ -40,6 +40,7 @@ export function PostsSection() {
title="Posts"
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
>
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder posts in Budget folders</a></p>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] text-sm">
@@ -52,7 +53,7 @@ export function PostsSection() {
</tr>
</thead>
<tbody>
{ordered.map((post, index) => (
{ordered.map((post) => (
<Fragment key={post.id}>
<tr className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
<td className="py-2">
@@ -102,18 +103,7 @@ export function PostsSection() {
</Button>
</td>
<td className="py-2">
{/* Three inline-flex siblings with nothing between them: the
hover backgrounds touched, so the row read as one wide
slab rather than three actions. */}
<div className="flex items-center justify-end gap-1">
<Button size="xs" variant="ghost" disabled={index === 0}
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
up
</Button>
<Button size="xs" variant="ghost" disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
down
</Button>
<Button size="xs" variant="ghost" className="-mr-2"
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
{post.archived ? "restore" : "archive"}
+2 -18
View File
@@ -8,7 +8,6 @@ import {
addRecurringCost,
addRecurringCostWithPost,
endRecurringCost,
moveRecurringCost,
restoreRecurringCost,
setRecurringCostEndedFrom,
updateRecurringCost,
@@ -213,6 +212,7 @@ export function RecurringSection() {
title="Recurring costs"
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
>
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder bills in Budget folders</a></p>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
{ordered.length === 0 ? (
<p className="text-sm text-budget-ink-muted">
@@ -233,7 +233,7 @@ export function RecurringSection() {
</tr>
</thead>
<tbody>
{ordered.map((cost, index) => (<Fragment key={cost.id}>
{ordered.map((cost) => (<Fragment key={cost.id}>
<tr
data-bill-id={cost.id}
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
@@ -325,22 +325,6 @@ export function RecurringSection() {
setEditingId(cost.id);
setEditSelection({ splitMode: cost.splitMode, splits: cost.splits.map(split => ({ ...split })) });
}}>Edit</Button>
<Button
size="xs"
variant="ghost"
disabled={index === 0}
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, -1))}
>
up
</Button>
<Button
size="xs"
variant="ghost"
disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, 1))}
>
down
</Button>
<Button
size="xs"
variant="ghost"