feat: organize posts and bills in settings
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user