From c93ff9908cff37f4282af2a0f6a5ea89809a7de0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 19:01:57 +0200 Subject: [PATCH 01/27] fix(rules): review and harden rule-history editing --- docs/TODO.md | 24 +----- src/export/json.test.ts | 13 ++++ src/export/json.ts | 23 ++++-- src/ui/components/RuleEditors.test.tsx | 101 +++++++++++++++++++++++++ src/ui/components/RuleHistory.tsx | 16 ++-- src/ui/routes/MonthRoute.tsx | 18 +++-- 6 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 src/ui/components/RuleEditors.test.tsx diff --git a/docs/TODO.md b/docs/TODO.md index 77c81db..69afe54 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,7 +8,7 @@ decisions behind it are in `docs/specs/` — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to `AGENTS.md`. -**Current state:** merged to `main`. 583 tests passing, +**Current state:** merged to `main`. 661 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. @@ -93,27 +93,7 @@ reason. Fine for one "$" currency; if the owner adds two, typing the code disambiguates and nothing warns them. Tested, documented, not solved. -## 4. Nobody has independently reviewed the rule-history editing surfaces - -Tasks 3-6 of the versioned-allocation-rules work — `setRuleFrom` / -`removeRuleFrom`, the JSON import validation, the Settings rule-history editor, -and the month view's "change from here" — were executed by hand rather than -dispatched, and no second pair of eyes has been over the diff. - -Worth reading first: the four commit messages, `de128e3`, `1cb91d3`, `e3023d0` -and `f94e458`. They record why each surface works the way it does, including -three of the four places the original briefs were wrong — a number field that -could not be cleared, a component that kept the previous post's rule because it -was not keyed, and missing validation that let a NaN allocation into the fold. - -(The fuller task log lived in a tool-managed ledger under `.superpowers/`, which -ignores its own contents and was never part of the repository. This file used to -send readers there, which worked only on the machine that wrote it.) - -Both surfaces have since been seen working in a browser. What is missing is -review, not verification. - -## 5. Still needs a human +## 4. Still needs a human `scripts/screenshot.ts` drives headless Chrome, so this list is much shorter than it was. Already seen and confirmed working: the carry meter (including red diff --git a/src/export/json.test.ts b/src/export/json.test.ts index cd8b2ef..7a75f19 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -230,6 +230,19 @@ describe("rule versions on import", () => { data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }]; expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/); }); + + test.each([ + ["missing percent", { kind: "percentOfIncome" }], + ["string percent", { kind: "percentOfIncome", percent: "10" }], + ["null percent", { kind: "percentOfIncome", percent: null }], + ["missing fixed amount", { kind: "fixed", amount: { currency: "DKK" } }], + ["string fixed amount", { kind: "fixed", amount: { amount: "500", currency: "DKK" } }], + ["null fixed amount", { kind: "fixed", amount: { amount: null, currency: "DKK" } }], + ])("rejects a %s before it can corrupt the fold", (_label, rule) => { + const data = populated() as any; + data.posts[0].rules = [{ from: "2026-01", rule }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError); + }); }); describe("the decimal-places setting on import", () => { diff --git a/src/export/json.ts b/src/export/json.ts index 7ae743d..4829bdc 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -301,18 +301,27 @@ export function parseDatasetJson(text: string): Dataset { } seenFrom.add(from); - // The rule's VALUE is deliberately unvalidated — a percentage above 100 - // and a negative amount are both legal. Its SHAPE is not: an unknown - // kind falls through resolveRule's percentage branch and turns every - // figure after it into NaN, and an unsupported currency throws - // MissingRateError deep inside the fold rather than here. + // Rule values may be negative or over 100, but must be finite numbers. + // A missing or non-numeric value can silently turn the fold into NaN. const rule = (version.rule ?? {}) as { kind?: unknown; - amount?: { currency?: unknown }; + amount?: { amount?: unknown; currency?: unknown }; + percent?: unknown; }; if (rule.kind === "fixed") { requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined); - } else if (rule.kind !== "percentOfIncome") { + if (typeof rule.amount?.amount !== "number" || !Number.isFinite(rule.amount.amount)) { + throw new ImportValidationError( + `Post "${post.name}" has a fixed rule from "${from}" with an amount that is not a finite number`, + ); + } + } else if (rule.kind === "percentOfIncome") { + if (typeof rule.percent !== "number" || !Number.isFinite(rule.percent)) { + throw new ImportValidationError( + `Post "${post.name}" has a percentage rule from "${from}" that is not a finite number`, + ); + } + } else { throw new ImportValidationError( `Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`, ); diff --git a/src/ui/components/RuleEditors.test.tsx b/src/ui/components/RuleEditors.test.tsx new file mode 100644 index 0000000..786b240 --- /dev/null +++ b/src/ui/components/RuleEditors.test.tsx @@ -0,0 +1,101 @@ +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 { afterEach, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { store } from "../../store/index.ts"; +import { RuleHistory } from "./RuleHistory.tsx"; +import { RuleFromMonth } from "../routes/MonthRoute.tsx"; + +const originalMutate = store.mutate; +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +function mount(child: React.ReactNode): HTMLDivElement { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root!.render(child)); + return container; +} + +afterEach(() => { + store.mutate = originalMutate; + if (root) act(() => root!.unmount()); + root = null; + container?.remove(); + container = null; +}); + +test("change from here preserves the currency of a prefilled fixed rule", async () => { + const data = createSeedDataset("2026-01"); + const post = data.posts[0]!; + const initial = { kind: "fixed" as const, amount: { amount: 100, currency: "USD" } }; + let saved = false; + store.mutate = async (change) => { + change(data); + saved = true; + }; + const el = mount( + {}} />, + ); + expect(el.querySelector('input[aria-label="Amount in USD"]')).not.toBeNull(); + + await act(async () => { + (Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click(); + }); + expect(saved).toBe(true); + expect(post.rules[0]!.rule).toEqual(initial); +}); + +test("change from here keeps the editor open and explains a failed write", async () => { + const data = createSeedDataset("2026-01"); + let closed = false; + store.mutate = async () => { + throw new Error("Disk full"); + }; + const el = mount( + { closed = true; }} + />, + ); + + await act(async () => { + (Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click(); + }); + expect(closed).toBe(false); + expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); +}); + +test("rule history explains a failed write", async () => { + const data = createSeedDataset("2026-01"); + const post = data.posts[0]!; + post.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }]; + store.mutate = async () => { + throw new Error("Disk full"); + }; + const el = mount(); + + await act(async () => { + (Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "remove")!).click(); + }); + expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); +}); + +test("rule history shows imported versions in chronological order", () => { + const data = createSeedDataset("2026-01"); + const post = data.posts[0]!; + post.rules = [ + { from: "2026-07", rule: { kind: "percentOfIncome", percent: 15 } }, + { from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }, + ]; + const el = mount(); + const months = Array.from(el.querySelectorAll("tbody tr td:first-child"), (cell) => cell.textContent); + expect(months).toEqual(["2026-01", "2026-07"]); +}); diff --git a/src/ui/components/RuleHistory.tsx b/src/ui/components/RuleHistory.tsx index 285e002..9d5feaf 100755 --- a/src/ui/components/RuleHistory.tsx +++ b/src/ui/components/RuleHistory.tsx @@ -27,12 +27,13 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; * so rather than letting it look like an append. */ export function RuleHistory({ post }: Props) { - const { mutate } = useMutate(); + const { mutate, error, clearError } = useMutate(); const [from, setFrom] = useState(currentMonth); const [kind, setKind] = useState("percentOfIncome"); // Held as text, not a number, so the field can be cleared and can hold a // half-typed "12." mid-entry. Parsed once, on submit. const [value, setValue] = useState(""); + const versions = [...post.rules].sort((a, b) => a.from.localeCompare(b.from)); const replaces = post.rules.some((v) => v.from === from); const monthOk = MONTH_ID.test(from); @@ -44,7 +45,8 @@ export function RuleHistory({ post }: Props) { kind === "fixed" ? { kind: "fixed", amount: { amount, currency: post.currency } } : { kind: "percentOfIncome", percent: amount }; - mutate((draft) => setRuleFrom(draft, post.id, from, rule)); + clearError(); + void mutate((draft) => setRuleFrom(draft, post.id, from, rule)); } return ( @@ -64,7 +66,7 @@ export function RuleHistory({ post }: Props) { - {post.rules.map((version) => ( + {versions.map((version) => ( {version.from} @@ -76,9 +78,10 @@ export function RuleHistory({ post }: Props) { @@ -137,6 +140,7 @@ export function RuleHistory({ post }: Props) { )} + {error &&

Could not save: {error}

} ); } diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index d62d41b..16a7e4d 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -300,7 +300,7 @@ function MonthStep({ to, label, back = false }: { to: string; label: string; bac * same `setRuleFrom` action the Settings history editor uses, so the two * surfaces cannot drift apart. */ -function RuleFromMonth({ +export function RuleFromMonth({ post, monthId, initial, @@ -311,7 +311,7 @@ function RuleFromMonth({ initial: Rule | undefined; onDone: () => void; }) { - const { mutate } = useMutate(); + const { mutate, error, clearError } = useMutate(); // Pre-filled with what is currently in effect, so nudging 10% to 15% is an // edit rather than re-entry. Empty when the post has no rule yet. Held as // text so the field can be cleared mid-edit; parsed once, on apply. @@ -321,6 +321,9 @@ function RuleFromMonth({ ? "" : String(initial.kind === "fixed" ? initial.amount.amount : initial.percent), ); + const [currency] = useState(() => + initial?.kind === "fixed" ? initial.amount.currency : post.currency, + ); const amount = Number(value); const valueOk = value.trim() !== "" && Number.isFinite(amount); @@ -328,10 +331,12 @@ function RuleFromMonth({ function apply() { const rule: Rule = kind === "fixed" - ? { kind: "fixed", amount: { amount, currency: post.currency } } + ? { kind: "fixed", amount: { amount, currency } } : { kind: "percentOfIncome", percent: amount }; - mutate((draft) => setRuleFrom(draft, post.id, monthId, rule)); - onDone(); + clearError(); + void mutate((draft) => setRuleFrom(draft, post.id, monthId, rule)).then((saved) => { + if (saved) onDone(); + }); } return ( @@ -355,7 +360,7 @@ function RuleFromMonth({ className="font-money h-8 w-24" type="number" step="0.01" - aria-label={kind === "fixed" ? `Amount in ${post.currency}` : "Percent of income"} + aria-label={kind === "fixed" ? `Amount in ${currency}` : "Percent of income"} value={value} onChange={(event) => { const next = event.target.value; @@ -368,6 +373,7 @@ function RuleFromMonth({ + {error &&

Could not save: {error}

} ); } From aed2313167598068755d20e5172c8399bb1532f4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 19:41:58 +0200 Subject: [PATCH 02/27] fix(ui): harden summary and settings workflows --- src/domain/views.test.ts | 9 +++ src/domain/views.ts | 21 ++++--- src/ui/components/ExpectedBand.tsx | 8 ++- src/ui/hooks/useMutate.ts | 5 +- src/ui/routes/SummaryRoute.test.ts | 10 +++ src/ui/routes/SummaryRoute.tsx | 40 ++++++++---- src/ui/routes/settings/CurrencySection.tsx | 14 ++++- src/ui/routes/settings/PostsSection.test.tsx | 65 ++++++++++++++++++++ src/ui/routes/settings/PostsSection.tsx | 13 ++-- src/ui/routes/settings/RecurringSection.tsx | 10 +-- 10 files changed, 159 insertions(+), 36 deletions(-) create mode 100644 src/ui/routes/SummaryRoute.test.ts create mode 100644 src/ui/routes/settings/PostsSection.test.tsx diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index b6ac8da..f5e855e 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -181,6 +181,15 @@ describe("yearView", () => { }); describe("summaryView", () => { + test("difference follows the dataset's decimal setting", () => { + const precise: Dataset = { + ...data, + settings: { ...data.settings, digits: 3 }, + months: [{ id: "2026-01", income: { amount: 1.234, currency: "DKK" }, ruleOverrides: {} }], + purchases: [spend("small", "food", 0.111, "2026-01-10")], + }; + expect(summaryView(precise, "2026-01", "2026-01").difference).toBe(1.123); + }); test("totals charges by post and by month across a range", () => { const view = summaryView(data, "2026-01", "2026-02"); expect(view.totalCharges).toBe(800); diff --git a/src/domain/views.ts b/src/domain/views.ts index d5f3c21..01145d8 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -149,6 +149,7 @@ export interface SummaryViewModel { byMonth: { monthId: MonthId; charges: number }[]; totalCharges: number; totalIncome: number; + difference: number; } export function summaryView( @@ -181,19 +182,23 @@ export function summaryView( ), })); + const totalCharges = roundMoney( + byMonth.reduce((sum, m) => sum + m.charges, 0), + baseDigits, + ); + const totalIncome = roundMoney( + months.reduce((sum, m) => sum + incomeFor(dataset, m), 0), + baseDigits, + ); + return { from, to, byPost, byMonth, - totalCharges: roundMoney( - byMonth.reduce((sum, m) => sum + m.charges, 0), - baseDigits, - ), - totalIncome: roundMoney( - months.reduce((sum, m) => sum + incomeFor(dataset, m), 0), - baseDigits, - ), + totalCharges, + totalIncome, + difference: roundMoney(totalIncome - totalCharges, baseDigits), }; } diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index ee7ba8f..47824cb 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -174,7 +174,7 @@ export function expectedGroups( */ export function ExpectedBand({ monthId }: { monthId: MonthId }) { const dataset = useDataset(); - const { mutate } = useMutate(); + const { mutate, error } = useMutate(); const [amounts, setAmounts] = useState>({}); const { pending, overdue, comingUp } = useMemo( @@ -239,7 +239,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { size="sm" variant="outline" disabled={invalid} - onClick={() => { + onClick={async () => { // Captured before mutate, per AGENTS.md: mutate defers behind // the write queue and React resets the input first, so reading // in the callback commits stale text. `invalid` already guards @@ -247,12 +247,13 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { // `parsed` is either a real amount or the field was never // touched. if (invalid) return; - mutate((draft) => + const saved = await mutate((draft) => confirmOccurrence(draft, occurrence.recurringId, occurrence.date, { date: recordedDate ?? undefined, amount: parsed ?? undefined, }), ); + if (!saved) return; setAmounts((current) => { const next = { ...current }; delete next[key]; @@ -272,6 +273,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { title="Expected" hint="Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards." > + {error &&

Could not confirm: {error}

} {pending.length > 0 && (
    {pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))} diff --git a/src/ui/hooks/useMutate.ts b/src/ui/hooks/useMutate.ts index c076f04..0e0a90b 100644 --- a/src/ui/hooks/useMutate.ts +++ b/src/ui/hooks/useMutate.ts @@ -18,7 +18,10 @@ export function useMutate() { const mutate = useCallback((fn: (draft: Dataset) => void): Promise => { return store.mutate(fn).then( - () => true, + () => { + setError(null); + return true; + }, (cause: unknown) => { setError(cause instanceof Error ? cause.message : String(cause)); return false; diff --git a/src/ui/routes/SummaryRoute.test.ts b/src/ui/routes/SummaryRoute.test.ts new file mode 100644 index 0000000..0fe0368 --- /dev/null +++ b/src/ui/routes/SummaryRoute.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { isMonthId } from "./SummaryRoute.tsx"; + +test("Summary range accepts real months and rejects partial or impossible input", () => { + expect(isMonthId("2026-09")).toBe(true); + expect(isMonthId("")).toBe(false); + expect(isMonthId("2026-0")).toBe(false); + expect(isMonthId("2026-13")).toBe(false); + expect(isMonthId("2026-09-01")).toBe(false); +}); diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx index bec49e6..3e819ac 100644 --- a/src/ui/routes/SummaryRoute.tsx +++ b/src/ui/routes/SummaryRoute.tsx @@ -5,10 +5,19 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { useDataset } from "../hooks/useDataset.ts"; import { datasetMonthSpan, summaryView } from "../../domain/views.ts"; +import { monthOf } from "../../domain/months.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { Section, Stat } from "../components/Section.tsx"; import { Segmented } from "../components/Segmented.tsx"; +export function isMonthId(value: string): boolean { + try { + return monthOf(value) === value; + } catch { + return false; + } +} + export function SummaryRoute() { const fmt = useMoneyFormat(); const dataset = useDataset(); @@ -17,9 +26,10 @@ export function SummaryRoute() { const [to, setTo] = useState(span.to); const [groupBy, setGroupBy] = useState<"post" | "month">("post"); - const view = summaryView(dataset, from, to); + const validFrom = isMonthId(from); + const validTo = isMonthId(to); + const view = validFrom && validTo ? summaryView(dataset, from, to) : null; const base = dataset.settings.baseCurrency; - const difference = Number((view.totalIncome - view.totalCharges).toFixed(2)); return (
    @@ -29,11 +39,11 @@ export function SummaryRoute() {
    - setFrom(e.target.value)} /> + setFrom(e.target.value)} />
    - setTo(e.target.value)} /> + setTo(e.target.value)} />
    -
    - {fmt.money(view.totalIncome, base)} - {fmt.money(view.totalCharges, base)} - - {fmt.money(difference, base)} - -
    + {view ? ( +
    + {fmt.money(view.totalIncome, base)} + {fmt.money(view.totalCharges, base)} + + {fmt.money(view.difference, base)} + +
    + ) : ( +

    Enter a month as YYYY-MM in each field.

    + )} -
    -
    + }
    ); } diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index 3a6ca09..5ac13ab 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -21,7 +21,7 @@ import type { Currency } from "../../../domain/types.ts"; export function CurrencySection() { const dataset = useDataset(); - const { mutate } = useMutate(); + const { mutate, error: writeError } = useMutate(); const base = dataset.settings.baseCurrency; const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL; const [status, setStatus] = useState(null); @@ -32,9 +32,13 @@ export function CurrencySection() { setStatus("Fetching…"); try { const rates = await fetchFxRates(apiUrl, base, targets); - mutate((draft) => { + const saved = await mutate((draft) => { for (const rate of rates) setFxRate(draft, rate); }); + if (!saved) { + setStatus(null); + return; + } const missing = targets.filter((t) => !rates.some((r) => r.currency === t)); setStatus( `Updated ${rates.length} rate(s).` + @@ -67,7 +71,11 @@ export function CurrencySection() { title="Currencies" hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals." > - {error &&

    {error}

    } + {(error || writeError) && ( +

    + {error ?? `Could not save: ${writeError}`} +

    + )}
    diff --git a/src/ui/routes/settings/PostsSection.test.tsx b/src/ui/routes/settings/PostsSection.test.tsx new file mode 100644 index 0000000..c1c01ac --- /dev/null +++ b/src/ui/routes/settings/PostsSection.test.tsx @@ -0,0 +1,65 @@ +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 { afterEach, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { createSeedDataset } from "../../../domain/seed.ts"; +import { store } from "../../../store/index.ts"; +import { PostsSection } from "./PostsSection.tsx"; + +const originalGet = store.get; +const originalMutate = store.mutate; +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + store.get = originalGet; + store.mutate = originalMutate; + if (root) act(() => root!.unmount()); + root = null; + container?.remove(); + container = null; +}); + +test("a failed post archive explains why the post stayed active", async () => { + const dataset = createSeedDataset("2026-01"); + store.get = () => dataset; + store.mutate = async () => { throw new Error("Disk full"); }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root!.render()); + + await act(async () => { + Array.from(container!.querySelectorAll("button")) + .find((button) => button.textContent === "archive")!.click(); + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); + expect(dataset.posts[0]!.archived).toBe(false); +}); + +test("a successful retry clears the previous save error", async () => { + const dataset = createSeedDataset("2026-01"); + store.get = () => dataset; + let fail = true; + store.mutate = async (change) => { + if (fail) throw new Error("Disk full"); + change(dataset); + }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root!.render()); + const archive = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "archive")!; + + await act(async () => { archive.click(); }); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + fail = false; + await act(async () => { archive.click(); }); + + expect(container.querySelector('[role="alert"]')).toBeNull(); +}); diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 9d06856..4b94af3 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -13,7 +13,7 @@ import { Section } from "../../components/Section.tsx"; export function PostsSection() { const dataset = useDataset(); - const { mutate } = useMutate(); + const { mutate, error } = useMutate(); const [newName, setNewName] = useState(""); const [openHistory, setOpenHistory] = useState(null); const ordered = [...dataset.posts].sort((a, b) => a.order - b.order); @@ -40,7 +40,9 @@ 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%." > - + {error &&

    Could not save: {error}

    } +
    +
    @@ -130,6 +132,7 @@ export function PostsSection() { ))}
    Name
    +

    Archiving hides a post from new purchases while keeping its history. Posts are never deleted, because purchases reference them. @@ -146,8 +149,10 @@ export function PostsSection() { variant="outline" disabled={newName.trim() === ""} onClick={() => { - mutate((draft) => addPost(draft, newName.trim(), base)); - setNewName(""); + const name = newName.trim(); + void mutate((draft) => addPost(draft, name, base)).then((saved) => { + if (saved) setNewName(""); + }); }} > Add post diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index 1b2fb0d..13dbe6a 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -95,7 +95,7 @@ export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoD export function RecurringSection() { const dataset = useDataset(); - const { mutate } = useMutate(); + const { mutate, error } = useMutate(); const [newName, setNewName] = useState(""); const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order); const base = dataset.settings.baseCurrency; @@ -202,6 +202,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." > + {error &&

    Could not save: {error}

    } {ordered.length === 0 ? (

    No recurring costs yet. Add rent, a subscription, or a phone bill below. @@ -369,7 +370,7 @@ export function RecurringSection() { disabled={newName.trim() === "" || !firstPost} onClick={() => { const name = newName.trim(); - mutate((draft) => + void mutate((draft) => addRecurringCost(draft, { name, archived: false, @@ -380,8 +381,9 @@ export function RecurringSection() { splitMode: "percent", splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }], }), - ); - setNewName(""); + ).then((saved) => { + if (saved) setNewName(""); + }); }} > Add recurring cost From ef5dfc1e6bc1322bb87b1b2c7ea8952ef261d875 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 19:52:39 +0200 Subject: [PATCH 03/27] ci: publish Docker image to Gitea registry --- .github/workflows/ci.yml | 67 +++++++++++++++++++++++++++++++++------- README.md | 20 ++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9261a06..3afc472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,77 @@ -name: CI +name: Continuous Integration on: push: branches: [main] + tags: ['v*'] pull_request: + branches: [main] + +env: + REGISTRY: gitea.lucaswinther.info jobs: - test: + lint-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: bun install --frozen-lockfile - - run: bun test - - run: bunx tsc --noEmit - - run: bun run build + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Typecheck + run: bunx tsc --noEmit + - name: Run test suite + run: bun test + - name: Build app + run: bun run build - docker: + docker-build-and-push: runs-on: ubuntu-latest + needs: [lint-and-test] steps: - uses: actions/checkout@v4 - - run: docker build -t budget2 . - - name: Serve the image and check it responds + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Gitea's job token cannot publish packages; use a package-write PAT. + - name: Log in to Gitea Container Registry + if: gitea.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ gitea.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Pull and smoke-test published image + if: gitea.event_name != 'pull_request' + env: + PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }} run: | - docker run -d --rm -p 3000:3000 --name budget2 budget2 + image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)" + docker pull "$image" + docker run -d --rm -p 3000:3000 --name budget2-ci "$image" + trap 'docker stop budget2-ci' EXIT for i in $(seq 1 30); do if curl -fsS http://localhost:3000/ > /dev/null; then break; fi sleep 1 @@ -33,4 +79,3 @@ jobs: curl -fsS http://localhost:3000/ | grep -q "Budget" curl -fsS http://localhost:3000/sw.js > /dev/null curl -fsS http://localhost:3000/manifest.webmanifest > /dev/null - docker stop budget2 diff --git a/README.md b/README.md index 380805c..56252dd 100755 --- a/README.md +++ b/README.md @@ -15,6 +15,26 @@ bun run build # production build into dist/ ## Self-hosting with Docker +Gitea Actions builds the image on pull requests and publishes it to the Gitea +container registry after the checks pass on `main` and `v*` tags. Published +images are available as `gitea.lucaswinther.info/lucasw89/budget2.0:latest`, +`:main`, `:sha-`, and `:` for version tags. + +Add a repository Actions secret named `REGISTRY_TOKEN` containing a personal +access token with package write permission. The token must belong to the user +who pushes the commit or tag, since the workflow logs in as the triggering +actor. The Gitea runner must have access to a Docker daemon. + +```bash +docker pull gitea.lucaswinther.info/lucasw89/budget2.0:latest +docker run --rm -p 3000:3000 gitea.lucaswinther.info/lucasw89/budget2.0:latest +``` + +For a private package, run `docker login gitea.lucaswinther.info` first with a +Gitea account or token that has package read permission. + +To build locally instead: + ```bash docker build -t budget2 . docker run --rm -p 3000:3000 budget2 From eea868c42207ecb79d916c0bad79e307f12d1622 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 19:56:29 +0200 Subject: [PATCH 04/27] ci: separate Gitea publishing from GitHub workflows --- .gitea/workflows/ci.yml | 81 ++++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 67 ++++++--------------------------- 2 files changed, 92 insertions(+), 56 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3afc472 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,81 @@ +name: Continuous Integration + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +env: + REGISTRY: gitea.lucaswinther.info + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Typecheck + run: bunx tsc --noEmit + - name: Run test suite + run: bun test + - name: Build app + run: bun run build + + docker-build-and-push: + runs-on: ubuntu-latest + needs: [lint-and-test] + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Gitea's job token cannot publish packages; use a package-write PAT. + - name: Log in to Gitea Container Registry + if: gitea.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ gitea.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Pull and smoke-test published image + if: gitea.event_name != 'pull_request' + env: + PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }} + run: | + image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)" + docker pull "$image" + docker run -d --rm -p 3000:3000 --name budget2-ci "$image" + trap 'docker stop budget2-ci' EXIT + for i in $(seq 1 30); do + if curl -fsS http://localhost:3000/ > /dev/null; then break; fi + sleep 1 + done + curl -fsS http://localhost:3000/ | grep -q "Budget" + curl -fsS http://localhost:3000/sw.js > /dev/null + curl -fsS http://localhost:3000/manifest.webmanifest > /dev/null diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3afc472..9261a06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,77 +1,31 @@ -name: Continuous Integration +name: CI on: push: branches: [main] - tags: ['v*'] pull_request: - branches: [main] - -env: - REGISTRY: gitea.lucaswinther.info jobs: - lint-and-test: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Install dependencies - run: bun install --frozen-lockfile - - name: Typecheck - run: bunx tsc --noEmit - - name: Run test suite - run: bun test - - name: Build app - run: bun run build + - run: bun install --frozen-lockfile + - run: bun test + - run: bunx tsc --noEmit + - run: bun run build - docker-build-and-push: + docker: runs-on: ubuntu-latest - needs: [lint-and-test] steps: - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # Gitea's job token cannot publish packages; use a package-write PAT. - - name: Log in to Gitea Container Registry - if: gitea.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ gitea.actor }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ gitea.repository }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=ref,event=branch - type=semver,pattern={{version}} - type=sha,prefix=sha- - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ gitea.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - - name: Pull and smoke-test published image - if: gitea.event_name != 'pull_request' - env: - PUBLISHED_TAGS: ${{ steps.meta.outputs.tags }} + - run: docker build -t budget2 . + - name: Serve the image and check it responds run: | - image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)" - docker pull "$image" - docker run -d --rm -p 3000:3000 --name budget2-ci "$image" - trap 'docker stop budget2-ci' EXIT + docker run -d --rm -p 3000:3000 --name budget2 budget2 for i in $(seq 1 30); do if curl -fsS http://localhost:3000/ > /dev/null; then break; fi sleep 1 @@ -79,3 +33,4 @@ jobs: curl -fsS http://localhost:3000/ | grep -q "Budget" curl -fsS http://localhost:3000/sw.js > /dev/null curl -fsS http://localhost:3000/manifest.webmanifest > /dev/null + docker stop budget2 From 490598629203362fe244b5131abeaf46d0a0133a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 19:59:20 +0200 Subject: [PATCH 05/27] ci: smoke-test image without binding a host port --- .gitea/workflows/ci.yml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3afc472..73b22a6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -70,12 +70,24 @@ jobs: run: | image="$(printf '%s\n' "$PUBLISHED_TAGS" | head -n 1)" docker pull "$image" - docker run -d --rm -p 3000:3000 --name budget2-ci "$image" + docker run -d --rm --name budget2-ci "$image" trap 'docker stop budget2-ci' EXIT - for i in $(seq 1 30); do - if curl -fsS http://localhost:3000/ > /dev/null; then break; fi - sleep 1 - done - curl -fsS http://localhost:3000/ | grep -q "Budget" - curl -fsS http://localhost:3000/sw.js > /dev/null - curl -fsS http://localhost:3000/manifest.webmanifest > /dev/null + docker exec budget2-ci bun -e ' + const base = "http://127.0.0.1:3000"; + let ready = false; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const response = await fetch(base + "/"); + if (response.ok && (await response.text()).includes("Budget")) { + ready = true; + break; + } + } catch {} + await Bun.sleep(1000); + } + if (!ready) throw new Error("App did not serve its HTML"); + for (const path of ["/sw.js", "/manifest.webmanifest"]) { + const response = await fetch(base + path); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + } + ' From f1e42d9d77b14693d85d4cfe63e1fd86e2e1e0b3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:13:45 +0200 Subject: [PATCH 06/27] docs: design budget folders and recurring cost placement --- ...ers-and-recurring-cost-placement-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md diff --git a/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md b/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md new file mode 100644 index 0000000..e772147 --- /dev/null +++ b/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md @@ -0,0 +1,123 @@ +# Budget folders and recurring cost placement + +## Intent + +The owner wants to organize a budget in folders and subfolders without making +every recurring bill an envelope. Rent may have its own post, while phone and +internet may share one post; the recurring rules remain individually editable. +The month page should show everyday posts such as Video Games, Events, and +Social cleanly alongside larger commitments. A bill's place in the folder tree +is independent of the post or posts its money uses. + +Success means the owner can choose or create a destination post when adding a +bill, change that destination later, organize posts and bills together in nested +folders, and read the scheduled bill total of a folder for a selected month. +The post fold, two-stage bill confirmation, and purchase ledger remain the +sources of financial truth. + +## Model + +Add `Dataset.folders: BudgetFolder[]`, with each folder carrying `id`, `name`, +`parentId: FolderId | null`, and `order`. Add +`folderId: FolderId | null` to both `Post` and `RecurringCost`. `null` means +Unfiled, a visible virtual root group rather than a stored folder. Existing +records migrate with `folderId: null`; the v7 -> v8 migration adds an empty +folder array and assigns one deterministic sibling order to the existing posts +and bills. A new dataset starts with an empty folder array too. + +Folders may nest to any depth. Sibling folders, posts, and bills share a +display order within their parent. The mutation layer assigns that order when +adding or moving an item and prevents a folder from becoming its own ancestor. +An item has one folder location; a recurring bill can still have splits into +several posts, including posts in other folders. Moving an item changes its +current organization across views, including past months, but never changes a +stored purchase, allocation rule, or computed post figure. + +A folder is never an envelope: it has no currency, allocation rule, rollover, +or purchase split target. Only posts have those behaviors. Bill splits retain +their current `Split[]` format and remainder handling. No occurrence is stored +to support folders. + +## Folder totals + +On the month page, each folder heading shows **Bills due** in the dataset's +base currency. The figure is the sum of the prices of recurring occurrences +whose scheduled slot falls in the displayed month and whose bill is placed in +that folder or a descendant folder. A bill in a different folder contributes +there, even when it charges a post in this folder. Each occurrence contributes +once, so a 28-day bill can contribute twice in a month. An ended bill contributes +only before its end date. An empty folder shows zero; a folder containing only +posts also shows zero bills due. + +For an unconfirmed occurrence, use its current expected amount. For a confirmed +occurrence, use the confirming purchase's actual total, because that is the +historical price paid. The slot month determines where the price appears; +`Purchase.date` continues to determine where the actual charge folds. Convert +each price to base using the same FX and `settings.digits` rules as the existing +money calculations, then round the sum at the boundary. A missing FX rate must +surface as an error, never a fallback. Negative prices remain allowed. + +This heading is a schedule total, not a balance. It does not add post +allocations, remaining balances, or purchase charges to bill prices. Thus a +folder holding both a Rent post and its 8,000 rent bill shows 8,000 in **Bills +due**, not 16,000. Post balances remain visible on the post row. Parent folder +totals include each descendant bill exactly once. + +## Month page and editing + +Replace the flat post section with a tree of collapsible folders. Within an +open folder, posts keep their existing balance figures, meter, post links, and +allocation editing. Recurring bills appear as distinct rows showing their +price, cadence, assigned posts, and next scheduled occurrence (or Ended); an +Edit action opens the bill editor. The folder heading shows its Bills due total for this +month. The Unfiled group holds existing items and any newly created item not +placed in a folder. Collapsed folders still show their total so the first page +stays scannable. Keyboard users can expand and collapse folders, and the mobile +view uses a list rather than squeezing the desktop financial table. + +Keep the due/overdue area as the fast reconciliation queue. It continues to +confirm occurrences into purchases using the existing rules, including +off-schedule payment dates. Bill rows in folders supply context and an editor, +not a second confirmation path or a second balance. + +In the add-bill flow, select an existing destination post, create a dedicated +post (for example Rent), or expand a split editor to assign several posts. +Saving is disabled until the split has a destination. Creating a post and bill +is one store mutation so a failed write leaves neither behind. A newly created +dedicated post starts in the bill's selected folder, but either item can be +moved independently afterward. Editing an existing bill exposes the same split +controls. The bill amount remains a text field using `parseMoneyInput`. + +Settings shows the same hierarchy for creating, renaming, reordering, moving, +and nesting folders and items. It also retains direct bill-rule editing and +post allocation history. Archived posts keep their fold history; ended bills +keep confirmations. The placement controls do not present folders as purchase +or allocation targets. + +## Boundaries and verification + +The domain provides a pure selector for the folder tree and scheduled totals, +using `occurrencesByMonth` rather than a second recurrence walk. It accepts the +displayed month from the caller and reads no clock. It never reimplements the +rollover fold. Store actions validate parent references, moves, cycles, and +split destinations. JSON import validates unique folder IDs, valid parent and +item references, and acyclic ancestry after migration. JSON export includes +the complete hierarchy; whole-dataset import keeps its existing backup gate. + +Tests cover migration from v7, nested totals without double counting, multiple +occurrences in one month, confirmed actual prices and off-schedule dates, bills +assigned to posts in another folder, moves and cycle rejection, and import +validation. UI checks cover choosing or creating a post, editing a bill split, +Unfiled, collapse controls, and desktop/mobile month layouts. Browser captures +check layout and console errors; they do not establish whether the design feels +good in use. + +## Alternatives considered + +- **Labels on a flat list:** simpler storage but no folder/subfolder hierarchy + or useful collapse behavior for the first page. +- **Nested posts as folders:** would make parent items look budgetable and mix + bill prices with envelope balances, obscuring where money is actually counted. +- **One tree containing folders, posts, and bill rules:** selected. It makes + placement explicit while keeping bill assignment and envelope accounting + separate. From 67c77eb2470e1f7a379179035a260a656853a099 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:18:25 +0200 Subject: [PATCH 07/27] docs: plan budget folders and recurring cost placement --- ...et-folders-and-recurring-cost-placement.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/plans/2026-09-29-budget-folders-and-recurring-cost-placement.md diff --git a/docs/plans/2026-09-29-budget-folders-and-recurring-cost-placement.md b/docs/plans/2026-09-29-budget-folders-and-recurring-cost-placement.md new file mode 100644 index 0000000..8472ede --- /dev/null +++ b/docs/plans/2026-09-29-budget-folders-and-recurring-cost-placement.md @@ -0,0 +1,143 @@ +# Budget Folders and Recurring Cost Placement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the owner place posts and editable recurring bills in nested folders, assign each bill to chosen posts, and see each folder's scheduled bill total on the month page. + +**Architecture:** Schema v8 adds folders and a location for each post and bill. Pure selectors assemble the mixed tree and calculate scheduled prices from the existing occurrence projector. Store actions own placement and cycle safety; the UI consumes those actions without changing the envelope fold or confirmation ledger. + +**Tech Stack:** Bun, TypeScript, React 19, React Router, IndexedDB snapshot store, Tailwind/shadcn components. + +**Spec:** `docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md` + +## Global Constraints + +- Bun only: `bun test`, `bunx tsc --noEmit`, `bun run build`. +- `src/domain/` stays pure and clock-free; scheduled occurrences remain computed, never stored. +- `settings.digits` controls all rounding; use `toBase` and `roundMoney` at money boundaries. Never use a missing FX fallback. +- A folder has no allocation rule, rollover, or purchase split identity. Posts alone hold balances. +- A folder's **Bills due** is scheduled occurrence prices in the selected month, including descendants; confirmed slots use the confirming purchase's actual total, even when `Purchase.date` is in another month. +- Keep purchase confirmation, `source.occurrenceDate`, post splits, and the single fold implementation intact. +- Imported datasets retain the pre-replacement backup gate. Store writes in UI use `useMutate`; event values are captured before its deferred callback. +- Spec and plan locations are `docs/specs/` and `docs/plans/`. + +## File Map + +- `src/domain/types.ts`, `seed.ts`: v8 folder types and fresh dataset shape. +- `src/store/migrations.ts`, `src/export/json.ts`: migration and import validation. +- `src/store/actions.ts`: folder creation, rename, and mixed sibling placement. +- `src/domain/budgetTree.ts`: pure mixed tree, bill slot totals, and month display data; `occurrences.ts` supplies a reusable next-slot query. +- `src/ui/routes/settings/BudgetFoldersSection.tsx`: folder and item placement controls. +- `src/ui/routes/settings/RecurringSection.tsx`, `src/ui/components/RecurringSplitEditor.tsx`: bill destinations and create-dedicated-post flow; a bill's month-page Edit link focuses its row in Settings. +- `src/ui/components/BudgetTree.tsx`, `src/ui/routes/MonthRoute.tsx`: grouped month presentation; reuse the post-row rendering from `PostTable` and `ExpectedBand` for confirmation. + +## Review Focus + +1. A bill assigned to a post in another folder must count in its own folder's Bills due and in that post's projected balance, once in each distinct measure. Pin in Task 3. +2. A confirmed slot paid in a different month must use its slot month for the folder total and its payment month for the post fold. Pin in Task 3. +3. A day/week bill can occur twice in one month, and an interval longer than 12 months can still have a correct next-slot label. Pin in Task 3. +4. A folder move to itself or a descendant must be rejected without changing the dataset; an imported orphan or cycle must also be rejected. Pin in Tasks 1 and 2. +5. A failed combined creation of a dedicated post and bill must leave neither saved, and an archived post must not be offered as a new destination. Pin in Task 4. + +--- + +### Task 1: Schema v8 and import boundary + +**Files:** +- Modify: `src/domain/types.ts`, `src/domain/seed.ts`, `src/store/migrations.ts`, `src/export/json.ts` +- Test: `src/domain/seed.test.ts`, `src/store/migrations.test.ts`, `src/export/json.test.ts` +- Modify: typed dataset fixtures reported by `bunx tsc --noEmit` + +**Interfaces:** +- Produces: `type FolderId = string`; `interface BudgetFolder { id: FolderId; name: string; parentId: FolderId | null; order: number }`; required `folderId: FolderId | null` on `Post` and `RecurringCost`; required `Dataset.folders: BudgetFolder[]`; `SCHEMA_VERSION = 8`. + +- [ ] **Step 1: Write failing tests.** Name cases `fresh dataset starts Unfiled`, `v7 migration preserves records and assigns mixed order`, and `import rejects invalid folder graph`. Assert `expect(data.folders).toEqual([])`, `expect(data.posts.every(p => p.folderId === null)).toBe(true)`, unchanged purchases/rules/splits, distinct root orders, and `expect(() => parseDatasetJson(json)).toThrow(ImportValidationError)` separately for duplicate IDs, missing parents, missing item folders, and cycles. A valid nested tree must parse. +- [ ] **Step 2: Run `bun test src/domain/seed.test.ts src/store/migrations.test.ts src/export/json.test.ts`.** Expected: the new v8 assertions fail. +- [ ] **Step 3: Add the schema and migration.** Append only a v7 -> v8 migration; do not rewrite old steps or import live defaults into a migration. Preserve old post order first, then old bill order. Validate folder references and cycles in `parseDatasetJson` after migration; accept `null` as Unfiled and require nonempty names and finite integer order values for folders and items. +- [ ] **Step 4: Run `bun test src/domain/seed.test.ts src/store/migrations.test.ts src/export/json.test.ts` and `bunx tsc --noEmit`.** Expected: pass. Update only fixture object literals the required new fields expose. +- [ ] **Step 5: Commit.** `git add src && git commit -m "feat: add budget folder schema and migration"`. + +### Task 2: Folder actions and mixed sibling ordering + +**Files:** +- Modify: `src/store/actions.ts` +- Test: `src/store/actions.test.ts` + +**Interfaces:** +- Consumes: Task 1 `BudgetFolder`, `FolderId`, and item `folderId`. +- Produces: `type BudgetItemKey = { kind: "folder" | "post" | "recurring"; id: string }`; `addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder`; `renameBudgetFolder(draft: Dataset, id: FolderId, name: string): void`; `placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void`. + +- [ ] **Step 1: Write failing action tests.** Name cases `mixed siblings reorder densely`, `invalid placement is atomic`, and `legacy move buttons use mixed order`. After placing a bill at index zero, assert `expect(siblings.map(item => item.order)).toEqual([0, 1, 2])` and `expect(siblings[0]!.id).toBe(bill.id)`. For each unknown destination, self/descendant move, nonexistent item, out-of-range index, and blank name, assert a throw and `expect(JSON.stringify(data)).toBe(before)`. Assert moving a folder to Unfiled works. +- [ ] **Step 2: Run `bun test src/store/actions.test.ts`.** Expected: the new action tests fail. +- [ ] **Step 3: Implement the three actions.** Resolve mixed siblings from folders, posts, and bills by `folderId`/`parentId`, splice a move into the requested destination, and renumber source and destination siblings. Keep `addPost` and `addRecurringCost` appending to the mixed Unfiled order; add an optional `folderId: FolderId | null = null` argument to `addPost` and use the cost's `folderId` when adding a bill. Delegate existing `movePost` and `moveRecurringCost` to `placeBudgetItem`; exclude `folderId` and `order` from their generic update actions so placement validation cannot be bypassed. +- [ ] **Step 4: Run `bun test src/store/actions.test.ts` and `bunx tsc --noEmit`.** Expected: pass. +- [ ] **Step 5: Commit.** `git add src/store && git commit -m "feat: manage nested budget folders"`. + +### Task 3: Pure folder tree and scheduled prices + +**Files:** +- Create: `src/domain/budgetTree.ts`, `src/domain/budgetTree.test.ts` +- Modify: `src/domain/occurrences.ts` +- Test: `src/domain/occurrences.test.ts` + +**Interfaces:** +- Consumes: `monthView(dataset, monthId).rows`, `occurrencesByMonth`, `toBase`, Task 1 folder fields. +- Produces: `budgetTree(dataset: Dataset, monthId: MonthId, rows: readonly MonthPostRow[]): BudgetTreeView`, where `BudgetTreeView` has ordered `roots: BudgetNode[]` and `unfiled: BudgetNode[]`; `BudgetNode` is a folder node with `billsDue` and `children`, a post node with its `MonthPostRow`, or a bill node with its `RecurringCost`, selected-month occurrences, and `nextOccurrence`. +- Produces: `nextUnconfirmedOccurrenceOnOrAfter(dataset: Dataset, recurringId: RecurringCostId, monthId: MonthId): Occurrence | null`, sharing the recurrence walk with `occurrencesOf` so long intervals have no arbitrary 12-month cutoff; `null` means ended. + +- [ ] **Step 1: Write failing selector tests.** Name cases `Housing counts rent once`, `nested totals follow bill placement`, `confirmed off-schedule slot uses actual price`, and `next slot exceeds preview horizon`. For Housing with a Rent post and bill, assert `expect(housing.billsDue).toBe(8000)`. For a 7,900 confirmation of a September slot paid in October, assert September Bills due is 7,900 and the October post charge is 7,900. Assert two 28-day slots contribute twice; FX at `digits: 0`, negative amounts, missing FX, empty/post-only folders, and a next occurrence beyond 12 months each get a direct expected value or throw assertion. +- [ ] **Step 2: Run `bun test src/domain/budgetTree.test.ts src/domain/occurrences.test.ts`.** Expected: new tests fail. +- [ ] **Step 3: Implement the selectors.** Use `occurrencesByMonth(dataset, monthId)` for selected-month totals, look up confirmed purchases by ID, call `toBase` for each occurrence, and `roundMoney` while summing. Build each item once from its location and shared order. Refactor the existing occurrence iterator only as needed to expose the next-slot query; keep its strict-advance throw and `lastCharge` behavior. +- [ ] **Step 4: Run `bun test src/domain/budgetTree.test.ts src/domain/occurrences.test.ts src/domain/charges.test.ts` and `bunx tsc --noEmit`.** Expected: pass. +- [ ] **Step 5: Commit.** `git add src/domain && git commit -m "feat: derive folder bill totals and mixed tree"`. + +### Task 4: Choose or create bill destination posts + +**Files:** +- Modify: `src/ui/routes/settings/RecurringSection.tsx`, `src/store/actions.ts` +- Create: `src/ui/components/RecurringSplitEditor.tsx`, `src/ui/components/RecurringSplitEditor.test.tsx` +- Test: `src/ui/routes/settings/RecurringSection.test.tsx`, `src/store/actions.test.ts` + +**Interfaces:** +- Consumes: Task 2 `addPost`/`addRecurringCost` and existing `updateRecurringCost`/`useMutate`. +- Produces: `addRecurringCostWithPost(draft: Dataset, cost: Omit, newPostName: string): RecurringCost` as one mutation for the dedicated-post path; it creates a 100% split to the new post. `RecurringSplitEditor` takes current mode/splits, active posts, and an `onChange` callback. + +- [ ] **Step 1: Write failing tests.** Name cases `bill requires an explicit destination`, `bill can split across active posts`, and `dedicated post and bill save together`. Assert `expect(saved.splits[0]!.postId).toBe(chosen.id)` where `chosen` is not the first post; assert edited mode/values/one absorber and changed `monthView(...).rows` projected figures. Assert dedicated records share `folderId`; after a rejected `store.mutate`, assert neither exists. Assert archived posts are absent from select options and `30$` yields USD. +- [ ] **Step 2: Run `bun test src/ui/components/RecurringSplitEditor.test.tsx src/ui/routes/settings/RecurringSection.test.tsx src/store/actions.test.ts`.** Expected: the new tests fail. +- [ ] **Step 3: Implement the add and edit controls.** Replace the first-post default with explicit selection. Keep simple one-post assignment as the default form state, reveal multiple splits on demand, and use the existing `roundSplits` path. Capture input values before `mutate`; keep a failed dialog open with its error visible. Implement the combined action without a second persistence call. Give each existing bill row a stable `bill-` anchor and focus target for the month-page Edit link. +- [ ] **Step 4: Run the focused tests and `bunx tsc --noEmit`.** Expected: pass. +- [ ] **Step 5: Commit.** `git add src/store src/ui && git commit -m "feat: choose recurring bill destinations"`. + +### Task 5: Manage folders and item placement in Settings + +**Files:** +- Create: `src/ui/routes/settings/BudgetFoldersSection.tsx`, `src/ui/routes/settings/BudgetFoldersSection.test.tsx` +- Modify: `src/ui/routes/SettingsRoute.tsx`, `src/ui/routes/settings/PostsSection.tsx`, `src/ui/routes/settings/RecurringSection.tsx` + +**Interfaces:** +- Consumes: Task 2 actions and Task 3 tree ordering; emits no new domain model. + +- [ ] **Step 1: Write failing UI tests.** Name cases `nested folder can be created and renamed`, `post and bill move independently`, and `failed move reports error`. After UI actions, assert `expect(post.folderId).toBe(housing.id)` and `expect(bill.folderId).toBe(utilities.id)`; moving a parent to Unfiled sets `parentId: null`. Assert descendant folders are absent from a parent move select, and a rejected save shows `[role="alert"]` while IDs and locations remain unchanged. +- [ ] **Step 2: Run `bun test src/ui/routes/settings/BudgetFoldersSection.test.tsx`.** Expected: new tests fail. +- [ ] **Step 3: Implement the hierarchy controls.** Use labeled native selects and buttons that work by keyboard. Wire `useMutate` for every store write. Keep post rule history and bill-rule editing accessible from their existing Settings sections; expose folder placement there or link clearly to the hierarchy section. +- [ ] **Step 4: Run focused settings tests and `bunx tsc --noEmit`.** Expected: pass. +- [ ] **Step 5: Commit.** `git add src/ui/routes && git commit -m "feat: organize posts and bills in settings"`. + +### Task 6: Group the month page + +**Files:** +- Create: `src/ui/components/BudgetTree.tsx`, `src/ui/components/BudgetTree.test.tsx` +- Modify: `src/ui/routes/MonthRoute.tsx`, `src/ui/components/PostTable.tsx`, `scripts/demo-data.ts`, `src/index.css` only if shared tokens are needed + +**Interfaces:** +- Consumes: Task 3 `budgetTree` and Task 4 bill editor; keeps `ExpectedBand` as the sole confirmation queue. + +- [ ] **Step 1: Write failing component tests.** Name cases `collapsed folder keeps Bills due`, `expanded tree preserves mixed order`, and `bill edit reaches its settings row`. With the repository's happy-dom/React `act` pattern, assert the Housing button's `getAttribute("aria-expanded")` is `"false"` while its parent text still includes `8,000` and `Bills due`. After activation, assert nested folder, Rent post, and Rent bill occur in sibling order; Unfiled shows migrated items. Assert bill Edit has `href="/settings#bill-"`, an ended bill reads Ended, and a long-interval bill shows its actual next slot. +- [ ] **Step 2: Run `bun test src/ui/components/BudgetTree.test.tsx`.** Expected: new tests fail. +- [ ] **Step 3: Implement the month grouping.** Extract reusable desktop post `` and mobile post `

  • ` renderers from `PostTable`, then render mixed items in sibling order: folder headings, bill rows, and post rows. Keep valid desktop table semantics and a separate mobile list. Bill rows show amount, cadence, assigned posts, and next slot, with no balance figure; Edit links to the Settings bill anchor from Task 4. Keep the global due/overdue band above the tree and preserve rule editing. Update synthetic demo data with a nested folder, a dedicated Rent post/bill, and a shared small-bills post. +- [ ] **Step 4: Run `bun test src/ui/components/BudgetTree.test.tsx`, `bun test`, `bunx tsc --noEmit`, and `bun run build`.** Expected: all pass. Start `bun run dev` in a live session. Run `bun scripts/demo-data.ts /tmp/seed.js`, then `bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/budget-folders-desktop.png --eval-file=/tmp/seed.js --reload --w=1280 --h=900` and the same command with `/tmp/budget-folders-mobile.png --w=390 --h=844`; inspect both PNGs and the script's console-error report. Capture dark theme too if the grouping adds theme-specific styles. Run the Impeccable mechanical detector once on changed UI targets after the final UI edits. +- [ ] **Step 5: Commit.** `git add src && git commit -m "feat: show budget folders on month page"`. + +## Completion check + +Run `git diff --check`, the full Bun suite, typecheck, and build after the final commit. Read `docs/ARCHITECTURE.md`, `docs/PRODUCT.md`, and `docs/DECISIONS.md` for any durable description that now needs updating; keep documentation changes scoped to the new hierarchy and its decision, then commit them. Report the tested behavior and browser checks separately from unverified offline, install, download, spreadsheet opening, and subjective design feel. From c34aa846b9670331289c6697c1e4a42b0eb597e9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:25:47 +0200 Subject: [PATCH 08/27] feat: add budget folder schema and migration --- scripts/demo-data.ts | 4 ++ src/domain/allocation.test.ts | 3 + src/domain/charges.test.ts | 4 ++ src/domain/currencyDigits.test.ts | 4 ++ src/domain/fold.test.ts | 6 ++ src/domain/occurrences.test.ts | 2 + src/domain/seed.test.ts | 6 ++ src/domain/seed.ts | 4 +- src/domain/types.ts | 11 ++++ src/domain/views.test.ts | 3 + src/export/json.test.ts | 56 ++++++++++++++++- src/export/json.ts | 68 ++++++++++++++++++++- src/store/actions.test.ts | 3 + src/store/actions.ts | 1 + src/store/migrations.test.ts | 34 ++++++++++- src/store/migrations.ts | 21 +++++++ src/store/snapshot.test.ts | 1 + src/ui/components/ExpectedBand.test.ts | 1 + src/ui/routes/settings/RecurringSection.tsx | 1 + 19 files changed, 227 insertions(+), 6 deletions(-) diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 1792d4d..995557d 100755 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -47,6 +47,7 @@ actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amou // every combination at once. const wowSub = actions.addRecurringCost(d, { name: "World of Warcraft", + folderId: null, archived: false, amount: { amount: 13, currency: "USD" }, startDate: "2026-06-05", @@ -57,6 +58,7 @@ const wowSub = actions.addRecurringCost(d, { }); actions.addRecurringCost(d, { name: "Rent", + folderId: null, archived: false, amount: { amount: 8200, currency: "DKK" }, startDate: "2026-06", @@ -67,6 +69,7 @@ actions.addRecurringCost(d, { }); actions.addRecurringCost(d, { name: "Veg box", + folderId: null, archived: false, amount: { amount: 175, currency: "DKK" }, startDate: "2026-06-06", @@ -79,6 +82,7 @@ actions.addRecurringCost(d, { // off-schedule payment moves the whole series — the phone-bill behaviour. actions.addRecurringCost(d, { name: "Car insurance", + folderId: null, archived: false, amount: { amount: 349, currency: "DKK" }, startDate: "2026-08-14", diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index f18fa24..8e16fc5 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -36,6 +36,7 @@ function post(overrides: Partial = {}): Post { id: "p1", name: "Food", order: 0, + folderId: null, archived: false, currency: "DKK", rules: [{ from: "2026-01", rule: fixed(400) }], @@ -48,6 +49,7 @@ function dataset(overrides: Partial = {}): Dataset { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, + folders: [], posts: [post()], months: [ { id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, @@ -191,6 +193,7 @@ describe("the fold across a rule change", () => { settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], + folders: [], posts: [ post({ rules: [ diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index c7e91c0..b3eba82 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -214,6 +214,7 @@ describe("chargesForMonth", () => { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, + folders: [], posts: [], months: [], purchases: [ @@ -233,6 +234,7 @@ describe("chargesForMonth", () => { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, + folders: [], posts: [], months: [], purchases: [], @@ -251,6 +253,7 @@ describe("expectedForMonth", () => { { code: "USD", symbol: "$", name: "US dollar" }, ], fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }], + folders: [], posts: [], months: [], purchases, @@ -262,6 +265,7 @@ describe("expectedForMonth", () => { id: "r1", name: "Rent", order: 0, + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index bb58637..469488b 100644 --- a/src/domain/currencyDigits.test.ts +++ b/src/domain/currencyDigits.test.ts @@ -96,11 +96,13 @@ describe("the fold at zero decimals", () => { fxRates: [ { currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" }, ], + folders: [], posts: [ { id: "food", name: "Food", order: 0, + folderId: null, archived: false, currency: "JPY", rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }], @@ -113,6 +115,7 @@ describe("the fold at zero decimals", () => { id: "fun", name: "Fun", order: 1, + folderId: null, archived: false, currency: "JPY", rules: [], @@ -134,6 +137,7 @@ describe("the fold at zero decimals", () => { id: "gym", name: "Gym", order: 0, + folderId: null, archived: false, amount: { amount: 7, currency: "USD" }, startDate: "2026-01", diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index d3f11fb..2ea0298 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -18,6 +18,7 @@ function post(id: string, fixed: number, archived = false): Post { id, name: id, order: 0, + folderId: null, archived, currency: "DKK", rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }], @@ -41,6 +42,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], + folders: [], posts, months: [ { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, @@ -131,6 +133,7 @@ test("percentage allocations follow the month's income", () => { id: "food", name: "Food", order: 0, + folderId: null, archived: false, currency: "DKK", rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }], @@ -191,11 +194,13 @@ function datasetWithRent(): Dataset { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], fxRates: [], + folders: [], posts: [ { id: "housing", name: "Housing", order: 0, + folderId: null, archived: false, currency: "DKK", rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }], @@ -208,6 +213,7 @@ function datasetWithRent(): Dataset { id: "rent", name: "Rent", order: 0, + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 235ebd4..2660298 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -7,6 +7,7 @@ function cost(overrides: Partial = {}): RecurringCost { id: "r1", name: "Rent", order: 0, + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", @@ -248,6 +249,7 @@ describe("occurrencesByMonth", () => { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], fxRates: [], + folders: [], posts: [], months: [], purchases, diff --git a/src/domain/seed.test.ts b/src/domain/seed.test.ts index 03bacfd..f692571 100644 --- a/src/domain/seed.test.ts +++ b/src/domain/seed.test.ts @@ -43,3 +43,9 @@ test("the seed dataset renders a valid month view", () => { test("newId returns distinct ids", () => { expect(newId()).not.toBe(newId()); }); + +test("fresh dataset starts Unfiled", () => { + const data = createSeedDataset("2026-09"); + expect(data.folders).toEqual([]); + expect(data.posts.every((post) => post.folderId === null)).toBe(true); +}); diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 77b99f7..9546075 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,7 +1,7 @@ import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts"; import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 7; +export const SCHEMA_VERSION = 8; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; @@ -24,6 +24,7 @@ export function createSeedDataset( id: newId(), name, order, + folderId: null, archived: false, currency: "DKK", // A new user has genuinely not budgeted anything yet. This is a different @@ -42,6 +43,7 @@ export function createSeedDataset( // out from under it the first time the user changes a rate or a currency. currencies: currencies.map((currency) => ({ ...currency })), fxRates: fxRates.map((rate) => ({ ...rate })), + folders: [], posts, months: [ { id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }, diff --git a/src/domain/types.ts b/src/domain/types.ts index 193c5e7..d52171e 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -54,6 +54,14 @@ export type MonthId = string; export type IsoDate = string; export type PostId = string; export type PurchaseId = string; +export type FolderId = string; + +export interface BudgetFolder { + id: FolderId; + name: string; + parentId: FolderId | null; + order: number; +} export type Rule = | { kind: "fixed"; amount: Money } @@ -69,6 +77,7 @@ export interface Post { id: PostId; name: string; order: number; + folderId: FolderId | null; archived: boolean; /** Display currency for this post's own views only. All totals use base. */ currency: Currency; @@ -138,6 +147,7 @@ export interface RecurringCost { id: RecurringCostId; name: string; order: number; + folderId: FolderId | null; /** * A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`, * exactly as an archived post still folds — archiving must not retroactively @@ -221,6 +231,7 @@ export interface Dataset { */ currencies: CurrencyDef[]; fxRates: FxRate[]; + folders: BudgetFolder[]; posts: Post[]; months: Month[]; purchases: Purchase[]; diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index f5e855e..243c1b8 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -17,6 +17,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos id, name: id, order, + folderId: null, archived, currency: "DKK", rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }], @@ -39,6 +40,7 @@ const data: Dataset = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], + folders: [], posts: [post("food", 0, 20), post("games", 1, 10)], months: [ { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, @@ -137,6 +139,7 @@ describe("monthView", () => { id: "r1", name: "Storage unit", order: 0, + folderId: null, archived: false, amount: { amount: 400, currency: "DKK" }, startDate: "2026-01", diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7a75f19..47fe198 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -114,6 +114,57 @@ describe("validation", () => { }); }); +describe("import rejects invalid folder graph", () => { + const folder = (id: string, parentId: string | null, order: number) => ({ id, name: id, parentId, order }); + const parse = (data: Dataset) => parseDatasetJson(JSON.stringify(data)); + + test("valid nested tree parses", () => { + const data = populated(); + data.folders = [folder("home", null, 0), folder("bills", "home", 0)]; + data.posts[0]!.folderId = "bills"; + expect(parse(data).posts[0]!.folderId).toBe("bills"); + }); + + test("rejects duplicate folder IDs", () => { + const data = populated(); + data.folders = [folder("same", null, 0), folder("same", null, 1)]; + expect(() => parse(data)).toThrow(ImportValidationError); + }); + + test("rejects missing parent", () => { + const data = populated(); + data.folders = [folder("child", "missing", 0)]; + expect(() => parse(data)).toThrow(ImportValidationError); + }); + + test("rejects missing item folders", () => { + const data = populated(); + data.posts[0]!.folderId = "missing"; + expect(() => parse(data)).toThrow(ImportValidationError); + data.posts[0]!.folderId = null; + data.recurring.push({ + id: "rent", name: "Rent", order: 3, folderId: "missing", archived: false, + amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + }); + expect(() => parse(data)).toThrow(ImportValidationError); + }); + + test("rejects cycles", () => { + const data = populated(); + data.folders = [folder("a", "b", 0), folder("b", "a", 0)]; + expect(() => parse(data)).toThrow(ImportValidationError); + }); + + test("rejects duplicate sibling order across item kinds", () => { + const data = populated(); + data.folders = [folder("bills", null, 0)]; + data.posts[0]!.order = 0; + expect(() => parse(data)).toThrow(ImportValidationError); + }); +}); + test("describeDataset counts what an import would replace", () => { expect(describeDataset(populated())).toEqual({ posts: 3, @@ -286,7 +337,8 @@ describe("recurring cost validation", () => { const valid = { id: "r1", name: "Rent", - order: 0, + order: 3, + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", @@ -530,7 +582,7 @@ describe("Purchase.source validation", () => { test("a source with a malformed occurrence date is refused", () => { const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" })); data.recurring = [{ - id: "r1", name: "Rent", order: 0, archived: false, + id: "r1", name: "Rent", order: 3, folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 1 }, diff --git a/src/export/json.ts b/src/export/json.ts index 4829bdc..59ff5dd 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -1,4 +1,4 @@ -import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts"; +import type { Currency, CurrencyDef, Dataset, FolderId, MonthId } from "../domain/types.ts"; import { migrate } from "../store/migrations.ts"; import { monthOf } from "../domain/months.ts"; import { toDayOrdinal } from "../domain/days.ts"; @@ -132,6 +132,71 @@ function requireArray(data: Record, key: string): unknown[] { return value; } +function requireFolderTree(data: Dataset): void { + const folders = requireArray(data as unknown as Record, "folders"); + const parents = new Map(); + const siblingOrders = new Map>(); + const requireOrder = (order: unknown, label: string) => { + if (typeof order !== "number" || !Number.isFinite(order) || !Number.isInteger(order)) { + throw new ImportValidationError(`${label} has an invalid order`); + } + }; + const requireLocation = (folderId: unknown, order: unknown, label: string) => { + if (folderId !== null && (typeof folderId !== "string" || !parents.has(folderId))) { + throw new ImportValidationError(`${label} references unknown folder "${String(folderId)}"`); + } + requireOrder(order, label); + const key = folderId as FolderId | null; + const orders = siblingOrders.get(key) ?? new Set(); + if (orders.has(order as number)) { + throw new ImportValidationError(`${label} duplicates sibling order ${String(order)}`); + } + orders.add(order as number); + siblingOrders.set(key, orders); + }; + + for (const entry of folders) { + const folder = entry as Record | null; + if (!folder || typeof folder.id !== "string" || !folder.id.trim()) { + throw new ImportValidationError("Folder has an invalid id"); + } + if (parents.has(folder.id)) { + throw new ImportValidationError(`Duplicate folder id "${folder.id}"`); + } + if (typeof folder.name !== "string" || !folder.name.trim()) { + throw new ImportValidationError(`Folder "${folder.id}" has an empty name`); + } + if (folder.parentId !== null && typeof folder.parentId !== "string") { + throw new ImportValidationError(`Folder "${folder.id}" has an invalid parent`); + } + parents.set(folder.id, folder.parentId as FolderId | null); + } + + for (const [id, parentId] of parents) { + if (parentId !== null && !parents.has(parentId)) { + throw new ImportValidationError(`Folder "${id}" references unknown parent "${parentId}"`); + } + const visited = new Set(); + let cursor: FolderId | null = id; + while (cursor !== null) { + if (visited.has(cursor)) throw new ImportValidationError(`Folder cycle at "${cursor}"`); + visited.add(cursor); + cursor = parents.get(cursor) ?? null; + } + } + + for (const entry of folders) { + const folder = entry as Record; + requireLocation(folder.parentId, folder.order, `Folder "${folder.id}"`); + } + for (const post of data.posts) { + requireLocation(post?.folderId, post?.order, `Post "${post?.name}"`); + } + for (const cost of data.recurring) { + requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`); + } +} + /** * `n` is checked hard rather than clamped. The projection walk in * `domain/occurrences.ts` terminates only if every step strictly advances, and @@ -331,6 +396,7 @@ export function parseDatasetJson(text: string): Dataset { const recurring = requireArray(data, "recurring") as any[]; dataset.recurring = recurring; + requireFolderTree(dataset); // Optional chaining, not `.id` directly: a null/non-object entry must fall // through to the loop below and fail there with an ImportValidationError, // not throw a raw TypeError here while merely collecting ids. diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index aef132a..090959d 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -464,6 +464,7 @@ describe("recurring costs", () => { const rentInput = { name: "Rent", + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", @@ -764,6 +765,7 @@ describe("confirmOccurrence", () => { data.posts[0]!.id = "housing"; actions.addRecurringCost(data, { name: "Rent", + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", @@ -836,6 +838,7 @@ describe("editing a confirmation's date", () => { data.posts[0]!.id = "housing"; actions.addRecurringCost(data, { name: "Car insurance", + folderId: null, archived: false, amount: { amount: 900, currency: "DKK" }, startDate: "2026-08-14", diff --git a/src/store/actions.ts b/src/store/actions.ts index 59093ac..d830c85 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -134,6 +134,7 @@ export function addPost(draft: Dataset, name: string, currency: Currency): Post id: newId(), name, order: draft.posts.length, + folderId: null, archived: false, currency, rules: [], diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index d1f27ce..69b5f3a 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -193,7 +193,7 @@ describe("v2 -> v3: currencies become data", () => { test("nothing else about the dataset is touched", () => { const before = v2(); const migrated = migrate(structuredClone(before)); - expect(migrated.posts).toEqual(before.posts); + expect(migrated.posts.map(({ folderId, ...post }) => post)).toEqual(before.posts); expect(migrated.months).toEqual(before.months); // Sterling's rate arrives later, in 4 -> 5; nothing this step does touches // the rate table. @@ -466,7 +466,7 @@ describe("6 -> 7: recurring costs join the dataset", () => { const v6: any = { ...createSeedDataset("2026-09"), recurring: existing }; v6.settings = { ...v6.settings, schemaVersion: 6 }; - expect(migrate(v6).recurring).toEqual(existing); + expect(migrate(v6).recurring).toEqual([{ ...existing[0], order: 3, folderId: null }]); }); test("NO figure changes: every post in every month folds identically", () => { @@ -485,3 +485,33 @@ describe("6 -> 7: recurring costs join the dataset", () => { } }); }); + +test("v7 migration preserves records and assigns mixed order", () => { + const data: any = createSeedDataset("2026-09"); + data.settings.schemaVersion = 7; + delete data.folders; + data.posts = [ + { ...data.posts[0], id: "later", order: 9, rules: [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 20, currency: "DKK" } } }] }, + { ...data.posts[1], id: "first", order: 2 }, + ].map(({ folderId, ...post }: any) => post); + data.recurring = [ + { id: "bill-later", name: "Later", order: 8, splits: [{ postId: "first", value: 100, absorbsRemainder: true }] }, + { id: "bill-first", name: "First", order: 1, splits: [{ postId: "later", value: 100, absorbsRemainder: true }] }, + ]; + data.purchases = [{ id: "purchase", splits: [{ postId: "later", value: 100, absorbsRemainder: true }] }]; + const before = structuredClone(data); + + const migrated = migrate(data); + + expect(migrated.folders).toEqual([]); + expect(migrated.posts.map((post) => [post.id, post.order, post.folderId])).toEqual([ + ["later", 1, null], ["first", 0, null], + ]); + expect(migrated.recurring.map((bill) => [bill.id, bill.order, bill.folderId])).toEqual([ + ["bill-later", 3, null], ["bill-first", 2, null], + ]); + expect(new Set([...migrated.posts, ...migrated.recurring].map((item) => item.order)).size).toBe(4); + expect(migrated.posts[0]!.rules).toEqual(before.posts[0].rules); + expect(migrated.recurring.map((bill) => bill.splits)).toEqual(before.recurring.map((bill: any) => bill.splits)); + expect(migrated.purchases).toEqual(before.purchases); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index de9ba06..f58f54a 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -193,6 +193,27 @@ const MIGRATIONS: Array<(data: any) => any> = [ settings: { ...data.settings, schemaVersion: 7 }, recurring: Array.isArray(data.recurring) ? data.recurring : [], }), + + // 7 -> 8: posts and recurring bills gain independent locations in a shared + // folder tree. Preserve each type's former display order, with posts first. + // The migration writes no financial data and reads no live default. + (data: any) => { + const posts = Array.isArray(data.posts) ? data.posts : []; + const recurring = Array.isArray(data.recurring) ? data.recurring : []; + const postOrders = new Map( + [...posts].sort((a: any, b: any) => a.order - b.order).map((post: any, index) => [post, index]), + ); + const billOrders = new Map( + [...recurring].sort((a: any, b: any) => a.order - b.order).map((bill: any, index) => [bill, posts.length + index]), + ); + return { + ...data, + settings: { ...data.settings, schemaVersion: 8 }, + folders: [], + posts: posts.map((post: any) => ({ ...post, order: postOrders.get(post), folderId: null })), + recurring: recurring.map((bill: any) => ({ ...bill, order: billOrders.get(bill), folderId: null })), + }; + }, ]; export function migrate(raw: unknown): Dataset { diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index 34a1fab..02ed8e7 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -181,6 +181,7 @@ describe("reset", () => { id: "extra", name: "Extra", order: 9, + folderId: null, archived: false, currency: "DKK", rules: [], diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts index 7f9ec59..7de253b 100644 --- a/src/ui/components/ExpectedBand.test.ts +++ b/src/ui/components/ExpectedBand.test.ts @@ -65,6 +65,7 @@ function cost(overrides: Partial = {}): RecurringCost { id: "r1", name: "Rent", order: 0, + folderId: null, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-01", diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index 13dbe6a..b4f8072 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -373,6 +373,7 @@ export function RecurringSection() { void mutate((draft) => addRecurringCost(draft, { name, + folderId: null, archived: false, amount: { amount: 0, currency: base }, startDate: currentMonth, From 046ca7824eb82d1bcf90debfab60d8d2275570c5 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:29:26 +0200 Subject: [PATCH 09/27] fix: reject blank imported budget item names --- src/export/json.test.ts | 17 +++++++++++++++++ src/export/json.ts | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 47fe198..c496d91 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -163,6 +163,23 @@ describe("import rejects invalid folder graph", () => { data.posts[0]!.order = 0; expect(() => parse(data)).toThrow(ImportValidationError); }); + + test("rejects a blank post name", () => { + const data = populated(); + data.posts[0]!.name = " "; + expect(() => parse(data)).toThrow(ImportValidationError); + }); + + test("rejects a blank recurring-cost name", () => { + const data = populated(); + data.recurring.push({ + id: "rent", name: " ", order: 3, folderId: null, archived: false, + amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + }); + expect(() => parse(data)).toThrow(ImportValidationError); + }); }); test("describeDataset counts what an import would replace", () => { diff --git a/src/export/json.ts b/src/export/json.ts index 59ff5dd..ee68998 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -190,9 +190,15 @@ function requireFolderTree(data: Dataset): void { requireLocation(folder.parentId, folder.order, `Folder "${folder.id}"`); } for (const post of data.posts) { + if (typeof post?.name !== "string" || !post.name.trim()) { + throw new ImportValidationError("Post has an empty name"); + } requireLocation(post?.folderId, post?.order, `Post "${post?.name}"`); } for (const cost of data.recurring) { + if (typeof cost?.name !== "string" || !cost.name.trim()) { + throw new ImportValidationError("Recurring cost has an empty name"); + } requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`); } } From 3f976e0943a6417ff6a392cd338b789755b26bfa Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:34:16 +0200 Subject: [PATCH 10/27] feat: manage nested budget folders --- src/store/actions.test.ts | 89 +++++++++++++++++++++++++-- src/store/actions.ts | 126 ++++++++++++++++++++++++++++++++------ 2 files changed, 191 insertions(+), 24 deletions(-) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 090959d..6f16896 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -4,6 +4,87 @@ import { createSeedDataset } from "../domain/seed.ts"; import { occurrencesByMonth } from "../domain/occurrences.ts"; import type { Dataset } from "../domain/types.ts"; +const billInput = (postId: string, folderId: string | null = null) => ({ + name: "Phone", folderId, archived: false, + amount: { amount: 30, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const, + splitMode: "percent" as const, + splits: [{ postId, value: 100, absorbsRemainder: true }], +}); + +describe("budget folder placement", () => { + test("mixed siblings reorder densely", () => { + const data = createSeedDataset("2026-09"); + const folder = actions.addBudgetFolder(data, "Utilities", null); + actions.addBudgetFolder(data, "Subscriptions", folder.id); + const post = actions.addPost(data, "Bills", "DKK", folder.id); + const bill = actions.addRecurringCost(data, billInput(post.id, folder.id)); + actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, folder.id, 0); + const siblings = actions.budgetSiblings(data, folder.id); + expect(siblings.map(item => item.order)).toEqual([0, 1, 2]); + expect(siblings[0]!.id).toBe(bill.id); + actions.placeBudgetItem(data, { kind: "folder", id: folder.id }, null, 0); + expect(actions.budgetSiblings(data, null)[0]!.id).toBe(folder.id); + }); + + test("invalid placement is atomic", () => { + const data = createSeedDataset("2026-09"); + const parent = actions.addBudgetFolder(data, "Parent", null); + const child = actions.addBudgetFolder(data, "Child", parent.id); + const post = actions.addPost(data, "Bills", "DKK", parent.id); + const bill = actions.addRecurringCost(data, billInput(post.id)); + const invalid = [ + () => actions.placeBudgetItem(data, { kind: "post", id: post.id }, "ghost", 0), + () => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, parent.id, 0), + () => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, child.id, 0), + () => actions.placeBudgetItem(data, { kind: "post", id: "ghost" }, null, 0), + () => actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 99), + () => actions.addBudgetFolder(data, " ", null), + () => actions.addBudgetFolder(data, "Unknown parent", "ghost"), + () => actions.renameBudgetFolder(data, parent.id, " "), + () => actions.addPost(data, "Orphan", "DKK", "ghost"), + () => actions.addRecurringCost(data, billInput(post.id, "ghost")), + () => actions.updatePost(data, post.id, { folderId: null } as never), + () => actions.updateRecurringCost(data, bill.id, { order: 0 } as never), + ]; + for (const operation of invalid) { + const before = JSON.stringify(data); + expect(operation).toThrow(); + expect(JSON.stringify(data)).toBe(before); + } + actions.renameBudgetFolder(data, parent.id, "New name"); + expect(parent.name).toBe("New name"); + actions.placeBudgetItem(data, { kind: "folder", id: child.id }, null, 0); + expect(child.parentId).toBe(null); + expect(actions.budgetSiblings(data, parent.id).map(item => item.order)).toEqual([0]); + }); + + test("legacy move buttons use mixed order", () => { + const data = createSeedDataset("2026-09"); + const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id)); + actions.moveRecurringCost(data, bill.id, -1); + expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([ + data.posts[0]!.id, data.posts[1]!.id, bill.id, data.posts[2]!.id, + ]); + actions.movePost(data, data.posts[2]!.id, -1); + expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([ + data.posts[0]!.id, data.posts[1]!.id, data.posts[2]!.id, bill.id, + ]); + const before = JSON.stringify(data); + actions.movePost(data, data.posts[0]!.id, -1); + expect(JSON.stringify(data)).toBe(before); + }); + + test("new items append after sparse imported sibling orders", () => { + const data = createSeedDataset("2026-09"); + data.posts[2]!.order = 3; + const folder = actions.addBudgetFolder(data, "Utilities", null); + const post = actions.addPost(data, "Bills", "DKK"); + const bill = actions.addRecurringCost(data, billInput(post.id)); + expect([folder.order, post.order, bill.order]).toEqual([4, 5, 6]); + }); +}); + /** * Decimal places are one dataset-wide setting, so a different minor unit is * expressed by moving `settings.digits` — no monkey-patching a module @@ -480,8 +561,8 @@ describe("recurring costs", () => { const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" }); expect(first.id).not.toBe(second.id); - expect(first.order).toBe(0); - expect(second.order).toBe(1); + expect(first.order).toBe(3); + expect(second.order).toBe(4); expect(data.recurring.length).toBe(2); }); @@ -754,8 +835,8 @@ describe("recurring costs", () => { const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" }); actions.moveRecurringCost(data, second.id, -1); - expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(0); - expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1); + expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(3); + expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(4); }); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index d830c85..697b118 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -8,6 +8,8 @@ import type { Currency, CurrencyDef, Dataset, + BudgetFolder, + FolderId, FxRate, IsoDate, Money, @@ -43,6 +45,88 @@ function requirePost(draft: Dataset, postId: PostId): Post { return post; } +export type BudgetItemKey = { kind: "folder" | "post" | "recurring"; id: string }; +type BudgetItem = BudgetFolder | Post | RecurringCost; +type BudgetSibling = BudgetItemKey & { order: number }; + +function siblingRefs(draft: Dataset, parentId: FolderId | null): Array<{ key: BudgetItemKey; item: BudgetItem }> { + return [ + ...draft.folders.filter(item => item.parentId === parentId).map(item => ({ key: { kind: "folder" as const, id: item.id }, item })), + ...draft.posts.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "post" as const, id: item.id }, item })), + ...draft.recurring.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "recurring" as const, id: item.id }, item })), + ].sort((a, b) => a.item.order - b.item.order); +} + +/** A clock-free, FX-free view of the shared order for Settings and placement. */ +export function budgetSiblings(draft: Dataset, parentId: FolderId | null): BudgetSibling[] { + return siblingRefs(draft, parentId).map(({ key, item }) => ({ ...key, order: item.order })); +} + +function nextSiblingOrder(draft: Dataset, parentId: FolderId | null): number { + const siblings = siblingRefs(draft, parentId); + return siblings.length ? siblings[siblings.length - 1]!.item.order + 1 : 0; +} + +function requireFolder(draft: Dataset, id: FolderId): BudgetFolder { + const folder = draft.folders.find(item => item.id === id); + if (!folder) throw new Error(`Unknown folder: ${id}`); + return folder; +} + +function requireFolderName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) throw new Error("Folder name cannot be blank"); + return trimmed; +} + +function requireBudgetItem(draft: Dataset, key: BudgetItemKey): BudgetItem { + if (key.kind === "folder") return requireFolder(draft, key.id); + if (key.kind === "post") return requirePost(draft, key.id); + if (key.kind === "recurring") return requireRecurringCost(draft, key.id); + throw new Error(`Unknown budget item kind: ${String(key.kind)}`); +} + +function itemParent(key: BudgetItemKey, item: BudgetItem): FolderId | null { + return key.kind === "folder" ? (item as BudgetFolder).parentId : (item as Post | RecurringCost).folderId; +} + +export function addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder { + const resolvedName = requireFolderName(name); + if (parentId !== null) requireFolder(draft, parentId); + const folder: BudgetFolder = { id: newId(), name: resolvedName, parentId, order: nextSiblingOrder(draft, parentId) }; + draft.folders.push(folder); + return folder; +} + +export function renameBudgetFolder(draft: Dataset, id: FolderId, name: string): void { + const folder = requireFolder(draft, id); + const resolvedName = requireFolderName(name); + folder.name = resolvedName; +} + +export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void { + const item = requireBudgetItem(draft, key); + if (destination !== null) requireFolder(draft, destination); + if (key.kind === "folder") { + let cursor = destination; + while (cursor !== null) { + if (cursor === key.id) throw new Error("A folder cannot be moved into itself or a descendant"); + cursor = requireFolder(draft, cursor).parentId; + } + } + const source = itemParent(key, item); + const sourceItems = siblingRefs(draft, source).filter(entry => !(entry.key.kind === key.kind && entry.key.id === key.id)); + const destinationItems = source === destination ? sourceItems : siblingRefs(draft, destination); + if (!Number.isInteger(index) || index < 0 || index > destinationItems.length) { + throw new Error(`Invalid sibling index: ${index}`); + } + if (key.kind === "folder") (item as BudgetFolder).parentId = destination; + else (item as Post | RecurringCost).folderId = destination; + destinationItems.splice(index, 0, { key, item }); + if (source !== destination) sourceItems.forEach((entry, order) => { entry.item.order = order; }); + destinationItems.forEach((entry, order) => { entry.item.order = order; }); +} + function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase { const purchase = draft.purchases.find((p) => p.id === purchaseId); if (!purchase) throw new Error(`Unknown purchase: ${purchaseId}`); @@ -129,12 +213,13 @@ export function setRuleOverride( month.ruleOverrides[postId] = roundRule(draft, rule); } -export function addPost(draft: Dataset, name: string, currency: Currency): Post { +export function addPost(draft: Dataset, name: string, currency: Currency, folderId: FolderId | null = null): Post { + if (folderId !== null) requireFolder(draft, folderId); const post: Post = { id: newId(), name, - order: draft.posts.length, - folderId: null, + order: nextSiblingOrder(draft, folderId), + folderId, archived: false, currency, rules: [], @@ -146,10 +231,11 @@ export function addPost(draft: Dataset, name: string, currency: Currency): Post export function updatePost( draft: Dataset, postId: PostId, - changes: Partial>, + changes: Partial>, ): void { const post = requirePost(draft, postId); - const resolved: Partial> = { ...changes }; + if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a post"); + const resolved: Partial> = { ...changes }; Object.assign(post, resolved); } @@ -163,13 +249,12 @@ export function setPostArchived( } export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): void { - const ordered = [...draft.posts].sort((a, b) => a.order - b.order); - const index = ordered.findIndex((p) => p.id === postId); + const post = requirePost(draft, postId); + const ordered = budgetSiblings(draft, post.folderId); + const index = ordered.findIndex(item => item.kind === "post" && item.id === postId); const target = index + direction; - if (index === -1 || target < 0 || target >= ordered.length) return; - const a = ordered[index]!; - const b = ordered[target]!; - [a.order, b.order] = [b.order, a.order]; + if (target < 0 || target >= ordered.length) return; + placeBudgetItem(draft, { kind: "post", id: postId }, post.folderId, target); } /** @@ -482,10 +567,11 @@ export function addRecurringCost( ): RecurringCost { const recurrence = requireRecurrence(cost.recurrence); requireStartDateGranularity(recurrence, cost.startDate); + if (cost.folderId !== null) requireFolder(draft, cost.folderId); const created: RecurringCost = { ...cost, id: newId(), - order: draft.recurring.length, + order: nextSiblingOrder(draft, cost.folderId), recurrence, amount: roundMoneyValue(draft, cost.amount), splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency), @@ -506,10 +592,11 @@ export function addRecurringCost( export function updateRecurringCost( draft: Dataset, id: RecurringCostId, - changes: Partial>, + changes: Partial>, ): void { const cost = requireRecurringCost(draft, id); - const resolved: Partial> = { ...changes }; + if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a recurring cost"); + const resolved: Partial> = { ...changes }; if (changes.recurrence) { resolved.recurrence = requireRecurrence(changes.recurrence); @@ -544,13 +631,12 @@ export function moveRecurringCost( id: RecurringCostId, direction: -1 | 1, ): void { - const ordered = [...draft.recurring].sort((a, b) => a.order - b.order); - const index = ordered.findIndex((c) => c.id === id); + const cost = requireRecurringCost(draft, id); + const ordered = budgetSiblings(draft, cost.folderId); + const index = ordered.findIndex(item => item.kind === "recurring" && item.id === id); const target = index + direction; - if (index === -1 || target < 0 || target >= ordered.length) return; - const a = ordered[index]!; - const b = ordered[target]!; - [a.order, b.order] = [b.order, a.order]; + if (target < 0 || target >= ordered.length) return; + placeBudgetItem(draft, { kind: "recurring", id }, cost.folderId, target); } /** From 390abf2be40569298c4e1bc4a12b343ce3cd5ebc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:36:33 +0200 Subject: [PATCH 11/27] fix: keep budget sibling order distinct at numeric limit --- src/store/actions.test.ts | 15 +++++++++++++++ src/store/actions.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 6f16896..e9ac285 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test"; import * as actions from "./actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; import { occurrencesByMonth } from "../domain/occurrences.ts"; +import { exportDatasetJson, parseDatasetJson } from "../export/json.ts"; import type { Dataset } from "../domain/types.ts"; const billInput = (postId: string, folderId: string | null = null) => ({ @@ -83,6 +84,20 @@ describe("budget folder placement", () => { const bill = actions.addRecurringCost(data, billInput(post.id)); expect([folder.order, post.order, bill.order]).toEqual([4, 5, 6]); }); + + test("adding after the largest finite sibling order stays importable", () => { + for (const add of [ + (data: Dataset) => actions.addBudgetFolder(data, "Utilities", null), + (data: Dataset) => actions.addPost(data, "Bills", "DKK"), + (data: Dataset) => actions.addRecurringCost(data, billInput(data.posts[0]!.id)), + ]) { + const data = createSeedDataset("2026-09"); + data.posts[2]!.order = Number.MAX_VALUE; + const added = add(data); + expect(added.order).not.toBe(Number.MAX_VALUE); + expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow(); + } + }); }); /** diff --git a/src/store/actions.ts b/src/store/actions.ts index 697b118..68a23fe 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -64,7 +64,12 @@ export function budgetSiblings(draft: Dataset, parentId: FolderId | null): Budge function nextSiblingOrder(draft: Dataset, parentId: FolderId | null): number { const siblings = siblingRefs(draft, parentId); - return siblings.length ? siblings[siblings.length - 1]!.item.order + 1 : 0; + if (!siblings.length) return 0; + const highest = siblings[siblings.length - 1]!.item.order; + const next = highest + 1; + if (Number.isFinite(next) && next > highest) return next; + siblings.forEach(({ item }, order) => { item.order = order; }); + return siblings.length; } function requireFolder(draft: Dataset, id: FolderId): BudgetFolder { From 1e30671d76bbd7c6535c868fefd53b57e62c51be Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:41:38 +0200 Subject: [PATCH 12/27] feat: derive folder bill totals and mixed tree --- src/domain/budgetTree.test.ts | 124 +++++++++++++++++++++++++++++++++ src/domain/budgetTree.ts | 87 +++++++++++++++++++++++ src/domain/index.ts | 1 + src/domain/occurrences.test.ts | 30 +++++++- src/domain/occurrences.ts | 43 +++++++++--- 5 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 src/domain/budgetTree.test.ts create mode 100644 src/domain/budgetTree.ts diff --git a/src/domain/budgetTree.test.ts b/src/domain/budgetTree.test.ts new file mode 100644 index 0000000..bdd9a19 --- /dev/null +++ b/src/domain/budgetTree.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "bun:test"; +import { budgetTree } from "./budgetTree.ts"; +import { MissingRateError } from "./fx.ts"; +import { createSeedDataset } from "./seed.ts"; +import type { BudgetFolder, Dataset, Post, Purchase, RecurringCost } from "./types.ts"; +import { monthView } from "./views.ts"; + +const post = (id: string, folderId: string | null, order = 0): Post => ({ + id, name: id, order, folderId, archived: false, currency: "DKK", rules: [], +}); +const folder = (id: string, parentId: string | null, order = 0): BudgetFolder => ({ id, name: id, parentId, order }); +const bill = (id: string, folderId: string | null, amount = 8000, order = 1): RecurringCost => ({ + id, name: id, order, folderId, archived: false, + amount: { amount, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], +}); +function dataset(): Dataset { + const data = createSeedDataset("2026-09"); + data.posts = []; + data.months = [{ id: "2026-09", income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }]; + return data; +} +const tree = (data: Dataset, month = "2026-09") => budgetTree(data, month, monthView(data, month).rows); + +test("Housing counts rent once", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", "housing")]; + data.recurring = [bill("rent-bill", "housing")]; + const housing = tree(data).roots[0]!; + expect(housing.kind).toBe("folder"); + if (housing.kind !== "folder") return; + expect(housing.billsDue).toBe(8000); + expect(housing.children.map(node => node.kind)).toEqual(["post", "bill"]); + expect(housing.children[1]!.kind).toBe("bill"); +}); + +test("nested totals follow bill placement", () => { + const data = dataset(); + data.folders = [folder("housing", null), folder("utilities", "housing")]; + data.posts = [post("rent", null)]; + data.recurring = [bill("phone", "utilities", 250)]; + const housing = tree(data).roots[0]!; + expect(housing.kind).toBe("folder"); + if (housing.kind !== "folder") return; + expect(housing.billsDue).toBe(250); + expect(housing.children[0]!.kind).toBe("folder"); + const utilities = housing.children[0]!; + if (utilities.kind !== "folder") return; + expect(utilities.billsDue).toBe(250); + expect(tree(data).unfiled.map(node => node.kind)).toEqual(["post"]); +}); + +test("confirmed off-schedule slot uses actual price", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", "housing")]; + data.recurring = [bill("rent-bill", "housing")]; + const purchase: Purchase = { + id: "paid", date: "2026-10-02", description: "Rent", + total: { amount: 7900, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], + schedule: null, source: { recurringId: "rent-bill", occurrenceDate: "2026-09" }, + }; + data.purchases = [purchase]; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(7900); + expect(monthView(data, "2026-10").rows[0]!.figures.charges).toBe(7900); +}); + +test("two 28-day slots contribute twice", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("twice", "housing", 50)]; + data.recurring[0]!.startDate = "2026-09-01"; + data.recurring[0]!.recurrence = { kind: "everyNDays", n: 28 }; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(100); + expect(housing.children[0]!.kind).toBe("bill"); + const node = housing.children[0]!; + if (node.kind === "bill") expect(node.occurrences.map(o => o.date)).toEqual(["2026-09-01", "2026-09-29"]); +}); + +test("FX rounds each price at dataset digits zero", () => { + const data = dataset(); + data.settings.digits = 0; + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("fx", "housing", 10.6)]; + data.recurring[0]!.amount.currency = "EUR"; + data.fxRates = [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-09-01", source: "manual" }]; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(80); +}); + +test("negative prices subtract from Bills due", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("refund", "housing", -25)]; + const housing = tree(data).roots[0]!; + if (housing.kind === "folder") expect(housing.billsDue).toBe(-25); +}); + +test("missing FX surfaces an error", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("fx", "housing")]; + data.recurring[0]!.amount.currency = "EUR"; + expect(() => budgetTree(data, "2026-09", [])).toThrow(MissingRateError); +}); + +test("empty and post-only folders have zero Bills due", () => { + const data = dataset(); + data.folders = [folder("empty", null), folder("post-only", null, 1)]; + data.posts = [post("rent", "post-only")]; + expect(tree(data).roots.map(node => node.kind === "folder" ? node.billsDue : NaN)).toEqual([0, 0]); +}); diff --git a/src/domain/budgetTree.ts b/src/domain/budgetTree.ts new file mode 100644 index 0000000..b0f424b --- /dev/null +++ b/src/domain/budgetTree.ts @@ -0,0 +1,87 @@ +import { toBase } from "./fx.ts"; +import { roundMoney } from "./money.ts"; +import { + nextUnconfirmedOccurrenceOnOrAfter, + occurrencesByMonth, + type Occurrence, +} from "./occurrences.ts"; +import type { BudgetFolder, Dataset, MonthId, RecurringCost } from "./types.ts"; +import type { MonthPostRow } from "./views.ts"; + +export type BudgetNode = + | { kind: "folder"; folder: BudgetFolder; billsDue: number; children: BudgetNode[] } + | { kind: "post"; row: MonthPostRow } + | { kind: "bill"; cost: RecurringCost; occurrences: Occurrence[]; nextOccurrence: Occurrence | null }; + +export interface BudgetTreeView { + roots: BudgetNode[]; + unfiled: BudgetNode[]; +} + +/** Pure month-specific placement and scheduled bill prices. */ +export function budgetTree( + dataset: Dataset, + monthId: MonthId, + rows: readonly MonthPostRow[], +): BudgetTreeView { + const slots = occurrencesByMonth(dataset, monthId).get(monthId) ?? []; + const purchases = new Map(dataset.purchases.map(purchase => [purchase.id, purchase])); + const slotsByBill = new Map(); + for (const slot of slots) { + const found = slotsByBill.get(slot.recurringId); + if (found) found.push(slot); + else slotsByBill.set(slot.recurringId, [slot]); + } + + type OrderedNode = { order: number; node: BudgetNode }; + const byParent = new Map(); + const add = (parent: string | null, order: number, node: BudgetNode): void => { + const found = byParent.get(parent); + if (found) found.push({ order, node }); + else byParent.set(parent, [{ order, node }]); + }; + + for (const folder of dataset.folders) { + add(folder.parentId, folder.order, { kind: "folder", folder, billsDue: 0, children: [] }); + } + for (const row of rows) { + add(row.post.folderId, row.post.order, { kind: "post", row }); + } + for (const cost of dataset.recurring) { + add(cost.folderId, cost.order, { + kind: "bill", + cost, + occurrences: slotsByBill.get(cost.id) ?? [], + nextOccurrence: nextUnconfirmedOccurrenceOnOrAfter(dataset, cost.id, monthId), + }); + } + + const digits = dataset.settings.digits; + const childrenOf = (parent: string | null): BudgetNode[] => + (byParent.get(parent) ?? []) + .sort((a, b) => a.order - b.order) + .map(({ node }) => { + if (node.kind === "folder") { + node.children = childrenOf(node.folder.id); + node.billsDue = node.children.reduce((sum, child) => + roundMoney(sum + (child.kind === "folder" ? child.billsDue : child.kind === "bill" ? billDue(child.occurrences) : 0), digits), 0); + } + return node; + }); + + const billDue = (occurrences: readonly Occurrence[]): number => + occurrences.reduce((sum, occurrence) => { + const purchase = occurrence.confirmedBy ? purchases.get(occurrence.confirmedBy) : null; + if (occurrence.confirmedBy && !purchase) { + throw new Error(`Missing confirming purchase: ${occurrence.confirmedBy}`); + } + const price = purchase?.total ?? occurrence.amount; + return roundMoney(sum + toBase(price, dataset.settings.baseCurrency, dataset.fxRates, digits), digits); + }, 0); + + const rootItems = childrenOf(null); + return { + roots: rootItems.filter(node => node.kind === "folder"), + unfiled: rootItems.filter(node => node.kind !== "folder"), + }; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index ed838dc..7104a7e 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -10,5 +10,6 @@ export * from "./charges.ts"; export * from "./occurrences.ts"; export * from "./fold.ts"; export * from "./views.ts"; +export * from "./budgetTree.ts"; export * from "./seed.ts"; export * from "./plans.ts"; diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 2660298..b55702f 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts"; +import { nextUnconfirmedOccurrenceOnOrAfter, occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts"; import type { Dataset, Purchase, RecurringCost } from "./types.ts"; function cost(overrides: Partial = {}): RecurringCost { @@ -287,3 +287,31 @@ describe("occurrencesByMonth", () => { expect(byMonth.has("2026-02")).toBe(false); }); }); + +test("next slot exceeds preview horizon", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], folders: [], posts: [], months: [], purchases: [], + recurring: [cost({ startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 24 } })], + }; + expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")?.date).toBe("2028-01"); + data.recurring[0]!.endedFrom = "2027-01"; + expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")).toBeNull(); +}); + +test("next-slot lookup rejects a recurrence that does not advance", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], folders: [], posts: [], months: [], purchases: [], + recurring: [cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 0 } })], + }; + expect(() => nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-01")).toThrow(/did not advance/); +}); + +test("bounded projection ignores invalid confirmations beyond its requested month", () => { + const phone = cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 30 }, anchoring: "lastCharge" }); + const future = new Map([["2026-03-02", confirmation("2026-03-02", "2025-11-02", "bad")]]); + expect(occurrencesOf(phone, future, "2026-02").map(slot => slot.date)).toEqual(["2026-01-01", "2026-01-31"]); +}); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index 3462c44..f9d48ea 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -141,24 +141,26 @@ export function occurrencesOf( confirmations: ReadonlyMap, upToMonth: MonthId, ): Occurrence[] { - const found: Occurrence[] = []; + return [...occurrenceWalk(cost, confirmations, upToMonth)]; +} + +/** The same walk serves bounded month projections and unbounded next-slot lookup. */ +function* occurrenceWalk( + cost: RecurringCost, + confirmations: ReadonlyMap, + upToMonth?: MonthId, +): Generator { // A monthly cost is month-granular throughout, even if its start date was // typed with a day. Rent occurs IN a month. let cursor = cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate; - while (compareMonths(monthOf(cursor), upToMonth) <= 0) { + while (true) { + if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break; if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break; const confirmation = confirmations.get(cursor) ?? null; - found.push({ - recurringId: cost.id, - date: cursor, - amount: cost.amount, - confirmedBy: confirmation?.id ?? null, - }); - // `lastCharge` measures from when the money ACTUALLY moved, which is what // makes an early charge rebase the series. An unconfirmed occurrence steps // from its own projected date: the projection assumes bills are paid on @@ -194,10 +196,31 @@ export function occurrencesOf( : `A recurrence must move strictly forward; check that n is at least 1.`), ); } + yield { + recurringId: cost.id, + date: cursor, + amount: cost.amount, + confirmedBy: confirmation?.id ?? null, + }; cursor = next; } +} - return found; +/** First still-open slot in or after the requested month, without a preview cutoff. */ +export function nextUnconfirmedOccurrenceOnOrAfter( + dataset: Dataset, + recurringId: RecurringCostId, + monthId: MonthId, +): Occurrence | null { + const cost = dataset.recurring.find(item => item.id === recurringId); + if (!cost) return null; + const confirmations = confirmationsFor(dataset.purchases, recurringId); + for (const occurrence of occurrenceWalk(cost, confirmations)) { + if (compareMonths(monthOf(occurrence.date), monthId) >= 0 && !occurrence.confirmedBy) { + return occurrence; + } + } + return null; } /** From 6de115843bf709b680d5f8f3aed963c91ec879ef Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:49:18 +0200 Subject: [PATCH 13/27] feat: choose recurring bill destinations --- src/store/actions.test.ts | 62 +++++++++++++ src/store/actions.ts | 26 ++++++ .../components/RecurringSplitEditor.test.tsx | 30 +++++++ src/ui/components/RecurringSplitEditor.tsx | 60 +++++++++++++ ...tion.test.ts => RecurringSection.test.tsx} | 72 +++++++++++++++ src/ui/routes/settings/RecurringSection.tsx | 88 ++++++++++++++----- 6 files changed, 318 insertions(+), 20 deletions(-) create mode 100644 src/ui/components/RecurringSplitEditor.test.tsx create mode 100644 src/ui/components/RecurringSplitEditor.tsx rename src/ui/routes/settings/{RecurringSection.test.ts => RecurringSection.test.tsx} (51%) diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index e9ac285..244563f 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -2,6 +2,8 @@ import { test, expect, describe } from "bun:test"; import * as actions from "./actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; import { occurrencesByMonth } from "../domain/occurrences.ts"; +import { monthView } from "../domain/views.ts"; +import { createSnapshotStore } from "./snapshot.ts"; import { exportDatasetJson, parseDatasetJson } from "../export/json.ts"; import type { Dataset } from "../domain/types.ts"; @@ -570,6 +572,66 @@ describe("recurring costs", () => { splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], }; + test("dedicated post and bill save together", () => { + const data = draft(); + const folder = actions.addBudgetFolder(data, "Housing", null); + const bill = actions.addRecurringCostWithPost(data, { + name: "Rent", folderId: folder.id, archived: false, + amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + }, "Rent post"); + const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!; + expect(post.name).toBe("Rent post"); + expect(post.folderId).toBe(folder.id); + expect(bill.folderId).toBe(folder.id); + expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]); + }); + + test("a rejected store mutation leaves neither dedicated record", async () => { + const data = draft(); + let writes = 0; + const snapshot = createSnapshotStore({ read: async () => data, write: async () => { writes++; throw new Error("Disk full"); } }, "2026-01"); + await snapshot.load(); + await expect(snapshot.mutate(draft => { + actions.addRecurringCostWithPost(draft, { + name: "Rent", folderId: null, archived: false, + amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + }, "Rent post"); + })).rejects.toThrow("Disk full"); + expect(writes).toBe(1); + expect(snapshot.get().posts.some(post => post.name === "Rent post")).toBe(false); + expect(snapshot.get().recurring).toHaveLength(0); + }); + + test("editing split mode, values and absorber moves projected figures", () => { + const data = draft(); + const other = data.posts[1]!; + const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, startDate: "2026-09" }); + expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(30); + actions.updateRecurringCost(data, bill.id, { + splitMode: "fixed", + splits: [ + { postId: "housing", value: 10, absorbsRemainder: false }, + { postId: other.id, value: 0, absorbsRemainder: true }, + ], + }); + expect(bill.splitMode).toBe("fixed"); + expect(bill.splits.map(split => split.absorbsRemainder)).toEqual([false, true]); + expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(10); + expect(monthView(data, "2026-09").rows.find(row => row.post.id === other.id)!.figures.expected).toBe(20); + }); + + test("recurring split destinations must be active posts", () => { + const data = draft(); + data.posts[1]!.archived = true; + expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i); + expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }] })).toThrow(/archived/i); + const bill = actions.addRecurringCost(data, rentInput); + expect(() => actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i); + expect(bill.splits).toEqual(rentInput.splits); + }); + test("adding assigns an id and an order", () => { const data = draft(); const first = actions.addRecurringCost(data, rentInput); diff --git a/src/store/actions.ts b/src/store/actions.ts index 68a23fe..1d7be5e 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -176,6 +176,14 @@ function roundSplits( })); } +function requireRecurringSplitDestinations(draft: Dataset, splits: Split[]): void { + if (splits.length === 0) throw new Error("A bill needs a destination post"); + for (const split of splits) { + const post = requirePost(draft, split.postId); + if (post.archived) throw new Error(`Post ${post.name} is archived`); + } +} + function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null { if (!schedule) return null; return { @@ -573,6 +581,7 @@ export function addRecurringCost( const recurrence = requireRecurrence(cost.recurrence); requireStartDateGranularity(recurrence, cost.startDate); if (cost.folderId !== null) requireFolder(draft, cost.folderId); + requireRecurringSplitDestinations(draft, cost.splits); const created: RecurringCost = { ...cost, id: newId(), @@ -585,6 +594,22 @@ export function addRecurringCost( return created; } +/** Creates the dedicated envelope and its bill in one store mutation. */ +export function addRecurringCostWithPost( + draft: Dataset, + cost: Omit, + newPostName: string, +): RecurringCost { + const name = newPostName.trim(); + if (!name) throw new Error("Post name cannot be blank"); + const post = addPost(draft, name, cost.amount.currency, cost.folderId); + return addRecurringCost(draft, { + ...cost, + splitMode: "percent", + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], + }); +} + /** * `archived` and `endedFrom` are excluded on purpose: they are a coherent * pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by @@ -617,6 +642,7 @@ export function updateRecurringCost( resolved.amount = roundMoneyValue(draft, changes.amount); } if (changes.splits) { + requireRecurringSplitDestinations(draft, changes.splits); // Either field may be absent from a partial update; the stored cost // supplies whichever one is, so the mode and currency always agree with // the values being rounded. diff --git a/src/ui/components/RecurringSplitEditor.test.tsx b/src/ui/components/RecurringSplitEditor.test.tsx new file mode 100644 index 0000000..abfc8bc --- /dev/null +++ b/src/ui/components/RecurringSplitEditor.test.tsx @@ -0,0 +1,30 @@ +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 { expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx"; + +test("bill can split across active posts", () => { + const data = createSeedDataset("2026-09"); + data.posts[0]!.archived = true; + const active = data.posts.filter(post => !post.archived); + let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] }; + const container = document.createElement("div"); + const root = createRoot(container); + const render = () => root.render( { selection = next; render(); }} />); + act(render); + expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull(); + act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); }); + expect(selection.splits).toHaveLength(2); + const selects = container.querySelectorAll("select"); + act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); }); + const radios = container.querySelectorAll('input[type="radio"]'); + act(() => { (radios[1] as HTMLInputElement).click(); }); + expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]); + expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]); + act(() => root.unmount()); +}); diff --git a/src/ui/components/RecurringSplitEditor.tsx b/src/ui/components/RecurringSplitEditor.tsx new file mode 100644 index 0000000..7fd41c1 --- /dev/null +++ b/src/ui/components/RecurringSplitEditor.tsx @@ -0,0 +1,60 @@ +import { Button } from "@/components/ui/button"; +import { useId } from "react"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import type { Post, RecurringCost, Split } from "../../domain/types.ts"; + +export type RecurringSplitSelection = Pick; + +interface Props extends RecurringSplitSelection { + posts: Post[]; + onChange: (next: RecurringSplitSelection) => void; +} + +export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) { + const absorberGroup = useId(); + function changeSplit(index: number, changes: Partial) { + onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) }); + } + + return ( +
    + Destination posts + {splits.length > 1 && ( + { + const mode = event.target.value as RecurringCost["splitMode"]; + onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) }); + }}> + + + + )} + {splits.map((split, index) => ( +
    + changeSplit(index, { postId: event.target.value })} + > + + {posts.map(post => )} + + {splits.length > 1 && ( + <> + changeSplit(index, { value: Number(event.target.value) || 0 })} /> + {splitMode === "percent" ? "%" : "amount"} + + + + )} +
    + ))} + +
    + ); +} diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.tsx similarity index 51% rename from src/ui/routes/settings/RecurringSection.test.ts rename to src/ui/routes/settings/RecurringSection.test.tsx index c276506..3532edc 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.tsx @@ -1,5 +1,77 @@ import { describe, expect, test } from "bun:test"; import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx"; +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 { addRecurringCost } from "../../../store/actions.ts"; +import { RecurringSection } from "./RecurringSection.tsx"; + +test("bill requires an explicit destination", async () => { + const data = createSeedDataset("2026-09"); + data.posts[0]!.archived = true; + 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()); + 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 add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!; + expect(add.disabled).toBe(true); + const chosen = data.posts[1]!; + const destination = container.querySelector('select[aria-label="Destination post"]') as HTMLSelectElement; + expect(destination.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull(); + act(() => { destination.value = chosen.id; destination.dispatchEvent(new Event("change", { bubbles: true })); }); + const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement; + act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30$"); amount.dispatchEvent(new InputEvent("input", { bubbles: true, data: "30$", inputType: "insertText" })); }); + expect(add.disabled).toBe(false); + await act(async () => { add.click(); }); + const saved = data.recurring[0]!; + expect(saved.splits[0]!.postId).toBe(chosen.id); + expect(saved.amount).toEqual({ amount: 30, currency: "USD" }); + } finally { + act(() => root.unmount()); container.remove(); + store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate; + } +}); + +test("failed destination edit keeps its editor and error visible", async () => { + const data = createSeedDataset("2026-09"); + const cost = addRecurringCost(data, { + name: "Phone", folderId: null, archived: false, + amount: { amount: 30, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + }); + const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate }; + store.get = () => data; + store.subscribe = () => () => {}; + store.mutate = async () => { throw new Error("Disk full"); }; + const container = document.createElement("div"); + const root = createRoot(container); + document.body.append(container); + try { + act(() => root.render()); + const edit = container.querySelector(`#bill-${cost.id}`) as HTMLButtonElement; + expect(edit).not.toBeNull(); + act(() => edit.click()); + await act(async () => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Save destinations")!).click(); }); + expect(container.textContent).toContain("Save destinations"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); + expect(cost.splits[0]!.postId).toBe(data.posts[0]!.id); + } finally { + act(() => root.unmount()); container.remove(); + store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate; + } +}); // C2: the "Starts" field must never be able to persist an empty or // malformed value — `isValidStartDate` is the guard the onBlur handler uses diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index b4f8072..8f7108a 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; @@ -6,6 +6,7 @@ import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; import { addRecurringCost, + addRecurringCostWithPost, endRecurringCost, moveRecurringCost, restoreRecurringCost, @@ -15,6 +16,7 @@ import { import { parseMoneyInput } from "../../moneyInput.ts"; import { currentMonth } from "../../../store/index.ts"; import { Section } from "../../components/Section.tsx"; +import { RecurringSplitEditor, type RecurringSplitSelection } from "../../components/RecurringSplitEditor.tsx"; import { monthOf } from "../../../domain/months.ts"; import { toDayOrdinal } from "../../../domain/days.ts"; import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts"; @@ -97,9 +99,18 @@ 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 [newPostName, setNewPostName] = useState(""); + const [folderId, setFolderId] = useState(null); + const [newSelection, setNewSelection] = useState({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); + const [editingId, setEditingId] = useState(null); + const [editSelection, setEditSelection] = useState(null); const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order); const base = dataset.settings.baseCurrency; - const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0]; + 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)); function amountCell(cost: RecurringCost) { return ( @@ -222,9 +233,9 @@ export function RecurringSection() { - {ordered.map((cost, index) => ( + {ordered.map((cost, index) => ( @@ -310,6 +321,10 @@ export function RecurringSection() {
    + + +
    +
  • + + )} + ))} @@ -363,35 +392,54 @@ export function RecurringSection() { className="w-48" placeholder="New cost name" value={newName} - onChange={(event) => setNewName(event.target.value)} + onInput={(event) => setNewName(event.currentTarget.value)} /> + setNewAmount(event.currentTarget.value)} /> + setFolderId(event.target.value || null)}> + + {dataset.folders.map(folder => )} + + setDestinationKind(event.target.value as "existing" | "dedicated")}> + + + + {destinationKind === "dedicated" ? ( + setNewPostName(event.target.value)} /> + ) : ( + + )} - {!firstPost && ( - Create a post first. - )} ); From 725c836788df03ad0420cdbcc44262b29b8567b8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 00:35:45 +0200 Subject: [PATCH 14/27] feat: organize posts and bills in settings --- src/ui/routes/SettingsRoute.tsx | 2 + .../settings/BudgetFoldersSection.test.tsx | 69 ++++++++++++++++ .../routes/settings/BudgetFoldersSection.tsx | 81 +++++++++++++++++++ src/ui/routes/settings/PostsSection.tsx | 16 +--- src/ui/routes/settings/RecurringSection.tsx | 20 +---- 5 files changed, 157 insertions(+), 31 deletions(-) create mode 100644 src/ui/routes/settings/BudgetFoldersSection.test.tsx create mode 100644 src/ui/routes/settings/BudgetFoldersSection.tsx diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index cb3becf..83928a6 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -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() {

    Settings

    + diff --git a/src/ui/routes/settings/BudgetFoldersSection.test.tsx b/src/ui/routes/settings/BudgetFoldersSection.test.tsx new file mode 100644 index 0000000..b68aae2 --- /dev/null +++ b/src/ui/routes/settings/BudgetFoldersSection.test.tsx @@ -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) => Promise, 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()); 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)); diff --git a/src/ui/routes/settings/BudgetFoldersSection.tsx b/src/ui/routes/settings/BudgetFoldersSection.tsx new file mode 100644 index 0000000..90aef2f --- /dev/null +++ b/src/ui/routes/settings/BudgetFoldersSection.tsx @@ -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(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
      {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
    • +
      +
      + {key.kind === "folder" ? { + const name = event.currentTarget.value; + if (name.trim()) void mutate(draft => renameBudgetFolder(draft, key.id, name)); + }} /> : {item.name}} + {key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"} +
      + { + 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)); + }}> + + {folders.filter(folder => allowedDestination(key, folder.id)).map(folder => )} + +
      + + +
      +
      + {key.kind === "folder" &&
      {children(key.id)}
      } +
    • ; + })}
    ; + } + + return
    + {error &&

    Could not save: {error}

    } +

    Top level · Unfiled

    + {children(null)} +
    + + + +
    +
    ; +} diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 4b94af3..f6819b0 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -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%." > +

    Move and reorder posts in Budget folders

    {error &&

    Could not save: {error}

    }
    @@ -52,7 +53,7 @@ export function PostsSection() { - {ordered.map((post, index) => ( + {ordered.map((post) => ( - {ordered.map((cost, index) => ( + {ordered.map((cost) => ( ({ ...split })) }); }}>Edit - - + Bills due {fmt.money(node.billsDue, dataset.settings.baseCurrency)} + ; + }; + const billContent = (node: Extract) =>
    +
    {node.cost.name} {fmt.money(node.cost.amount.amount, node.cost.amount.currency)}Edit
    +

    {cadence(node.cost.recurrence)} · Assigned to {node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}

    +
    ; + const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => { + const indent = Math.min(depth, 4) * 16; + const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.cost.id; + if (node.kind === "post") return [mobile ? : ]; + const name = node.kind === "folder" ? node.folder.name : node.cost.name; + const content = node.kind === "folder" ? folderHeading(node) : billContent(node); + const style = { paddingInlineStart: `${indent}px` }; + const item = mobile ?
  • {content}
  • :
    ; + return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])]; + }); + return <> +
    @@ -102,18 +103,7 @@ export function PostsSection() { - {/* Three inline-flex siblings with nothing between them: the - hover backgrounds touched, so the row read as one wide - slab rather than three actions. */}
    - -
    {content}
    {["Post / bill", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => )} + {render(tree.roots, false)} + {tree.unfiled.length > 0 && }{render(tree.unfiled, false)} +
    {label}
    Unfiled
    +
      {render(tree.roots, true)}{tree.unfiled.length > 0 &&
    • Unfiled
    • }{render(tree.unfiled, true)}
    + ; +} diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 0690c38..43a5ece 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -173,7 +173,6 @@ function ChangeRuleButton({ } export function PostTable({ monthId, rows, onChangeRule }: Props) { - const fmt = useMoneyFormat(); return ( <>
    @@ -194,11 +193,32 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { belong together, which is exactly how the meter came to look like a divider between two rows rather than part of one. */} {rows.map((row) => { - const cells = postRowCells(row, fmt); return ( - + + ); + })} + +
    + +
      + {rows.map((row) => { + return ( + + ); + })} +
    + + ); +} + +interface PostRowProps { row: MonthPostRow; monthId: MonthId; onChangeRule?: (postId: string) => void; indent?: number; } + +export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0 }: PostRowProps) { + const cells = postRowCells(row, useMoneyFormat()); + return ( + - + {onChangeRule && ( - ); - })} - -
    + ); +} -
      - {rows.map((row) => { - const cells = postRowCells(row, fmt); - return ( -
    • +export function MobilePostRow({ row, monthId, onChangeRule, indent = 0 }: PostRowProps) { + const cells = postRowCells(row, useMoneyFormat()); + return ( +
    • @@ -281,9 +298,5 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { )}

    • - ); - })} -
    - ); } diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 16a7e4d..b3b8a5d 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -13,7 +13,8 @@ import { ruleAt } from "../../domain/allocation.ts"; import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts"; import { sliceAmountForMonth } from "../../domain/charges.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; -import { PostTable, PostTableLegend } from "../components/PostTable.tsx"; +import { PostTableLegend } from "../components/PostTable.tsx"; +import { BudgetTree } from "../components/BudgetTree.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; import { ExpectedBand } from "../components/ExpectedBand.tsx"; import { Meter } from "../components/Meter.tsx"; @@ -128,11 +129,11 @@ export function MonthRoute() {
    } className="overflow-hidden" > - +
    {changingPost && ( From abf57a963f885eb00160933f18750739af309a9b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 00:50:42 +0200 Subject: [PATCH 17/27] docs: explain budget folder placement and totals --- docs/ARCHITECTURE.md | 10 ++++++++++ docs/DECISIONS.md | 8 ++++++++ docs/PRODUCT.md | 3 +++ 3 files changed, 21 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 256a802..a8bbf2f 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,6 +204,16 @@ is first and stored, every XML part parses, and numeric cells carry behavioural one, because the bug it prevents depends on real browser render timing that happy-dom does not reproduce. +## Budget placement + +Folders organize posts and recurring bills in one sibling order. The pure +`budgetTree` selector combines month post rows with computed scheduled slots; +folder Bills due sums bill prices in the selected slot month, including +descendants, using confirmed purchase totals where available. Folder placement +is independent of bill split destinations. Only posts have allocations and +rollover balances. The month UI renders the same tree as a desktop table and a +mobile list, and keeps occurrence confirmation in the Expected band. + ## Where the reasoning lives - `AGENTS.md` — the rules, in enforceable one-line form, and which test fails diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c77b5be..18baca8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -75,6 +75,14 @@ record. - **Posts are archived, never deleted**, because purchases reference them. Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md) +## Budget folders + +- **Folders contain posts and recurring bills in a mixed order.** Folders carry + scheduled Bills due totals; allocation and rollover remain on posts. Bill + placement and split destinations are independent. Rejected nested posts as + folders because that would mix schedule prices and envelope balances. + → [`specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md`](specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md) + ## Recurring costs - **Two-stage confirmation, over projection-only or generation-only.** A diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index cb93375..d959926 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -54,6 +54,9 @@ Money is handled in DKK, USD and EUR, with DKK as the base. Confirmed and in place: +- Nested budget folders organize posts and bills independently of bill split + destinations. Folder Bills due reports scheduled prices for the selected month; + posts alone hold envelope balances. - Posts (envelopes) with monthly allocation, created and archived by the owner. Posts are archived, never deleted, because purchases reference them. - Allocation is either a fixed amount or a percentage of that month's income. From 228d48d97836507d23e9952530b6333ef59e45fb Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 00:54:15 +0200 Subject: [PATCH 18/27] fix: clarify currencies in mixed budget legend --- src/ui/components/BudgetTree.test.tsx | 11 ++++++++++- src/ui/components/PostTable.tsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ui/components/BudgetTree.test.tsx b/src/ui/components/BudgetTree.test.tsx index df54080..c98681d 100644 --- a/src/ui/components/BudgetTree.test.tsx +++ b/src/ui/components/BudgetTree.test.tsx @@ -9,6 +9,7 @@ import { createSeedDataset } from "../../domain/seed.ts"; import { addBudgetFolder, addRecurringCost, confirmOccurrence } from "../../store/actions.ts"; import { monthView } from "../../domain/views.ts"; import { store } from "../../store/index.ts"; +import { PostTableLegend } from "./PostTable.tsx"; import { BudgetTree } from "./BudgetTree.tsx"; function setup(run: (container: HTMLDivElement, billId: string) => void, confirm = false) { @@ -21,7 +22,7 @@ function setup(run: (container: HTMLDivElement, billId: string) => void, confirm if (confirm) confirmOccurrence(data, bill.id, "2026-09", { date: "2026-09-01" }); const original = { get: store.get, subscribe: store.subscribe }; store.get = () => data; store.subscribe = () => () => {}; const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); - try { act(() => root.render()); run(container, bill.id); } + try { act(() => root.render()); run(container, bill.id); } finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); } } function housing(container: HTMLDivElement) { return container.querySelector('button[aria-label="Housing"]') as HTMLButtonElement; } @@ -48,3 +49,11 @@ test("bill edit reaches its settings row", () => setup((container, billId) => { expect(container.textContent).toContain("Ended"); expect(container.textContent).toContain("Next 2028-03"); }, true)); + +test("legend scopes base currency to post figures and identifies bill currencies", () => setup(container => { + const legend = container.querySelector("span.flex.flex-wrap")!; + expect(legend.textContent).toContain("Post figures in DKK."); + expect(legend.textContent).toContain("Bill prices show their own currency."); + expect(legend.textContent).toContain("Projected subtracts bills not yet confirmed."); + expect(legend.textContent).not.toContain("All figures"); +})); diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 43a5ece..34d5477 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -76,7 +76,7 @@ export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) { return ( - All figures in {baseCurrency}. Projected subtracts bills not yet confirmed. + Post figures in {baseCurrency}. Bill prices show their own currency. Projected subtracts bills not yet confirmed. From fc14341f1efe8ee8613e971de9a237e3b9137a81 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 00:57:50 +0200 Subject: [PATCH 19/27] docs: update folder worktree state and follow-up queue --- docs/DECISIONS.md | 6 +++--- docs/TODO.md | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 18baca8..54b9290 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -228,8 +228,8 @@ Not oversights. Each was decided against, and reopening one needs a reason. amounts are all **allowed**. Adding validation is a regression. → [`AGENTS.md`](../AGENTS.md) §3 - The income field and the split editor's fixed-amount fields **do not parse a - currency**; only the purchase total does. → [`TODO.md`](TODO.md) §3 + currency**; only the purchase total does. → [`TODO.md`](TODO.md) §5 - **No currency picker.** Adding a currency means typing code, name, symbol and - decimals by hand. → [`TODO.md`](TODO.md) §3 + decimals by hand. → [`TODO.md`](TODO.md) §5 - **Symbol collisions resolve to the first currency defined with that symbol.** - Tested and documented rather than solved. → [`TODO.md`](TODO.md) §3 + Tested and documented rather than solved. → [`TODO.md`](TODO.md) §5 diff --git a/docs/TODO.md b/docs/TODO.md index 69afe54..5ea03a4 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,15 +8,19 @@ decisions behind it are in `docs/specs/` — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to `AGENTS.md`. -**Current state:** merged to `main`. 661 tests passing, -`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and -offline paths. +**Current feature worktree state:** budget folders and recurring cost placement +are implemented. +707 tests passing across 54 files. `bunx tsc --noEmit` is clean; +`bun run --bun build` succeeds using the committed seed rates when live-fetch +is unavailable. The runtime override bypasses a broken local Bun shim. -**Schema is at version 7.** v1 -> v2 made allocation rules a dated series; +**Schema is at version 8.** v1 -> v2 made allocation rules a dated series; v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service URL that can no longer work; v4 -> v5 adds sterling to an existing dataset; v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7 -adds `Dataset.recurring`. All six are behaviour-preserving by construction — +adds `Dataset.recurring`; v7 -> v8 adds budget folders and assigns existing +posts and recurring bills a deterministic mixed sibling order under Unfiled. +All seven are behaviour-preserving by construction — not one changes a stored figure — and are tested as such. A migration applies **on load, in memory**; it is written back on the next @@ -27,7 +31,24 @@ IndexedDB instead of the app. --- -## 1. Visual redesign, sub-project 3 — Year, Summary, Post detail +## 1. Folder rename blur during an in-flight save + +A folder name draft can remain unsaved if the owner changes it while a rename +save is in flight and then blurs the field before that save finishes. Preserve +the newer draft and ensure the blur requests a subsequent save after the pending +write completes. Cover the delayed-write sequence with a UI interaction test, +including the final persisted name and any surfaced write error. + +## 2. Recurring split editor interaction coverage + +`RecurringSplitEditor` has coverage for destination selection and dedicated post +creation, but its rendered split mode and value controls lack a focused +interaction test. Exercise the real controls to switch percentage/fixed mode +and edit a split value, then assert the resulting splits and remainder behavior. +Keep this scoped to the editor's user interaction; domain split calculations +already have their own tests. + +## 3. Visual redesign, sub-project 3 — Year, Summary, Post detail The owner's brief for the whole redesign: *"right now it looks a bit boring, simple and ugly. We want sleek, modern, and nice to use."* On scoping: both @@ -67,7 +88,7 @@ than re-decide** (spec: `2026-09-02-month-view-design.md`): phone. `formatMoney` is still right wherever an amount can be in some other currency — every purchase row, for one. -## 2. Visual redesign, sub-project 4 — dialogs and fast entry +## 4. Visual redesign, sub-project 4 — dialogs and fast entry Untouched. Native ` { @@ -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.

    ); } From b0e8145d2ead1c37f0f37f05a4e4650c513b53ff Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 13:09:07 +0200 Subject: [PATCH 24/27] feat: support unassigned purchases and allocation status Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting. --- docs/ARCHITECTURE.md | 16 +++++- docs/PRODUCT.md | 5 ++ src/domain/charges.ts | 14 +++++ src/domain/types.ts | 3 +- src/domain/views.test.ts | 56 +++++++++++++++++++ src/domain/views.ts | 45 ++++++++++++---- src/export/json.test.ts | 11 ++-- src/export/json.ts | 12 +++-- src/export/ods.test.ts | 18 +++++++ src/export/ods.ts | 19 ++++++- src/store/actions.test.ts | 10 ++++ src/ui/bulkEntry.test.ts | 11 ++-- src/ui/bulkEntry.ts | 10 ++-- src/ui/components/PlanEditor.test.tsx | 48 +++++++++++++++++ src/ui/components/PlanEditor.tsx | 16 +++--- src/ui/components/PostTable.test.tsx | 43 +++++++++++++++ src/ui/components/PostTable.tsx | 10 ++-- src/ui/components/PurchaseDialog.tsx | 20 ++++++- src/ui/components/RuleHistory.tsx | 4 +- src/ui/components/SplitEditor.tsx | 22 ++++++++ src/ui/components/YearMatrix.tsx | 13 ++++- src/ui/meterSegments.test.ts | 5 ++ src/ui/meterSegments.ts | 15 +++++- src/ui/purchaseForm.test.ts | 10 ++-- src/ui/purchaseForm.ts | 7 +-- src/ui/routes/MonthRoute.tsx | 6 +++ src/ui/routes/NoAllocationViews.test.tsx | 68 ++++++++++++++++++++++++ src/ui/routes/PostMonthRoute.tsx | 6 +-- src/ui/routes/PostYearRoute.tsx | 9 ++-- src/ui/routes/SummaryRoute.tsx | 15 +++++- src/ui/routes/YearRoute.tsx | 2 +- 31 files changed, 469 insertions(+), 80 deletions(-) create mode 100644 src/ui/components/PlanEditor.test.tsx create mode 100644 src/ui/components/PostTable.test.tsx create mode 100644 src/ui/routes/NoAllocationViews.test.tsx diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb3ee41..bb792e3 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,8 +47,9 @@ remaining = carriedIn + allocation − charges (carried forward, monthly) Unspent carries; overspend carries as debt. **Nothing else may recompute this.** The month view, year matrix, summary, per-post views and the ODS export all -aggregate over `foldBalances` / `figuresFor`. A closing balance is read from -`byMonth[11].remaining`, never re-summed. +aggregate envelope figures over `foldBalances` / `figuresFor`. Overall Spent +also includes unassigned purchase slices, which have no envelope to fold. A +closing balance is read from `byMonth[11].remaining`, never re-summed. A recurring cost adds a SECOND balance beside it, on its own carry: @@ -156,6 +157,17 @@ those parts*. Both split modes collapse to one code path per slice. Applying a split's raw value per-slice looks simpler and passes every percentage test — and charges a fixed "2000 to Video Games" in full on every slice. +A purchase with no post has an empty `splits` array. Its dated amount counts in +monthly, yearly and summary Spent totals, but charges no envelope and changes no +rollover balance. Recurring bills still require a destination post. JSON import +and ODS export preserve these unassigned purchases. + +A post with no applicable allocation rule has no overspend status, even if its +balance is negative. The balance remains visible and carries normally. An +explicit zero rule, including a zero monthly override, does have an overspend +status when its balance is negative. The views use the presence of a rule rather +than infer it from the numeric allocation, since both cases fold to zero. + ## Things the app deliberately does NOT validate Adding a "missing" guard here is a regression, not a fix: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d959926..57b9355 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -59,10 +59,15 @@ Confirmed and in place: posts alone hold envelope balances. - Posts (envelopes) with monthly allocation, created and archived by the owner. Posts are archived, never deleted, because purchases reference them. +- A post with no applicable allocation rule still records spending and shows its + balance, but is not labelled overspent. An explicit zero allocation is a + budget rule and can be overspent. - Allocation is either a fixed amount or a percentage of that month's income. - A purchase can be split across several posts, by percentage or by fixed amounts, and can also be spread across future months as a payment plan. The two compose. +- A purchase may have no post. It remains visible in its month and counts in + Spent, while leaving envelope balances unchanged. - Per-month income, entered directly, varying month to month. - Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table, with an optional on-demand fetch that fails soft and is never required. diff --git a/src/domain/charges.ts b/src/domain/charges.ts index 25406b3..e230ba7 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -125,6 +125,7 @@ export function chargesForPurchaseInMonth( ): Charge[] { const slice = sliceAmountForMonth(purchase, monthId); if (!slice) return []; + if (purchase.splits.length === 0) return []; // Split proportions come from the WHOLE purchase, so a financed purchase // divides each slice in the same ratio as the purchase as a whole — which is @@ -140,6 +141,19 @@ export function chargesForPurchaseInMonth( ); } +/** Spending with no envelope destination in one month, in base currency. */ +export function looseChargesForMonth(dataset: Dataset, monthId: MonthId): number { + return dataset.purchases.reduce((sum, purchase) => { + if (purchase.splits.length !== 0) return sum; + const slice = sliceAmountForMonth(purchase, monthId); + if (!slice) return sum; + return roundMoney( + sum + toBase(slice, dataset.settings.baseCurrency, dataset.fxRates, dataset.settings.digits), + dataset.settings.digits, + ); + }, 0); +} + /** Total charged per post in `monthId`, in base currency. */ export function chargesForMonth( dataset: Dataset, diff --git a/src/domain/types.ts b/src/domain/types.ts index d52171e..2eee7d1 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -169,7 +169,7 @@ export interface RecurringCost { endedFrom?: IsoDate; recurrence: Recurrence; anchoring: Anchoring; - /** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */ + /** Bills require exactly one remainder-absorbing destination. */ splitMode: "percent" | "fixed"; splits: Split[]; } @@ -183,6 +183,7 @@ export interface Purchase { /** In the purchase's own currency. */ total: Money; splitMode: "percent" | "fixed"; + /** Empty means spent in the month without charging any post. */ splits: Split[]; /** null means the whole total is charged in the month of `date`. */ schedule: Schedule | null; diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index 243c1b8..a217640 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -55,6 +55,33 @@ const data: Dataset = { }; describe("monthView", () => { + test("counts a loose purchase as spent without charging a post", () => { + const loose: Dataset = { ...data, purchases: [{ ...spend("loose", "food", 75, "2026-01"), splits: [] }] }; + const view = monthView(loose, "2026-01"); + expect(view.totalCharges).toBe(75); + expect(view.rows.every((row) => row.figures.charges === 0)).toBe(true); + expect(view.rows[0]!.figures.remaining).toBe(2000); + }); + + test("counts financed loose slices in their months and converts currency", () => { + const loose: Dataset = { + ...data, + fxRates: [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-01-01", source: "manual" }], + purchases: [{ + ...spend("loose", "food", 10, "2026-01"), + total: { amount: 10, currency: "EUR" }, + splits: [], + schedule: { slices: [ + { month: "2026-01", amount: { amount: 4, currency: "EUR" } }, + { month: "2026-02", amount: { amount: 6, currency: "EUR" } }, + ] }, + }], + }; + expect(monthView(loose, "2026-01").totalCharges).toBe(30); + expect(monthView(loose, "2026-02").totalCharges).toBe(45); + expect(yearView(loose, 2026).totalCharges).toBe(75); + expect(summaryView(loose, "2026-01", "2026-02").looseCharges).toBe(75); + }); test("reports income, allocations, and the unallocated remainder", () => { const view = monthView(data, "2026-01"); expect(view.income).toBe(10000); @@ -104,6 +131,35 @@ describe("monthView", () => { expect(view.overspentCount).toBe(0); }); + test("no allocation rule stays distinct from an explicit zero allocation", () => { + const unbudgeted = { ...post("none", 0, 0), rules: [] }; + const zero = post("zero", 1, 0); + const caseData: Dataset = { + ...data, + posts: [unbudgeted, zero], + purchases: [spend("a", "none", 25, "2026-01"), spend("b", "zero", 25, "2026-01")], + }; + const view = monthView(caseData, "2026-01"); + expect(view.rows.map((row) => row.hasAllocationRule)).toEqual([false, true]); + expect(view.rows.map((row) => row.figures.remaining)).toEqual([-25, -25]); + expect(view.overspentCount).toBe(1); + expect(yearView(caseData, 2026).rows.map((row) => row.hasAllocationRuleByMonth[0])).toEqual([false, true]); + }); + + test("a monthly override counts as an allocation rule even before any standing rule", () => { + const caseData: Dataset = { + ...data, + posts: [{ ...post("none", 0, 0), rules: [] }], + months: [{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: { + none: { kind: "fixed", amount: { amount: 0, currency: "DKK" } }, + } }], + purchases: [spend("a", "none", 25, "2026-01")], + }; + const view = monthView(caseData, "2026-01"); + expect(view.rows[0]!.hasAllocationRule).toBe(true); + expect(view.overspentCount).toBe(1); + }); + test("archived posts appear only when they have activity", () => { const withArchived: Dataset = { ...data, diff --git a/src/domain/views.ts b/src/domain/views.ts index 01145d8..61e01fe 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -1,4 +1,5 @@ -import { incomeFor, isOverridden } from "./allocation.ts"; +import { incomeFor, isOverridden, ruleForPost } from "./allocation.ts"; +import { looseChargesForMonth } from "./charges.ts"; import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts"; import { roundMoney } from "./money.ts"; import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts"; @@ -8,6 +9,8 @@ export interface MonthPostRow { post: Post; figures: PostMonthFigures; overridden: boolean; + /** An applicable standing rule or monthly override, including an explicit zero. */ + hasAllocationRule: boolean; } export interface MonthViewModel { @@ -15,11 +18,13 @@ export interface MonthViewModel { income: number; totalAllocation: number; totalCharges: number; + looseCharges: number; /** income - totalAllocation. Negative when allocations exceed income. */ unallocated: number; /** - * How many rows are overspent — `remaining < 0`, so a post at exactly zero - * does not count. Computed here rather than in the component because it is + * How many budgeted rows are overspent — `remaining < 0` with an applicable + * allocation rule. A post with no rule still shows its negative balance, + * but has no budget to overspend. Computed here because it is * the month-level answer to "did I overspend", and it belongs where the * rest of the month's arithmetic is tested. */ @@ -57,17 +62,22 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { const figures = figuresFor(fold, post.id, monthId); // Archived posts stay visible only while they still have activity. if (post.archived && !hasActivity(figures)) continue; - rows.push({ post, figures, overridden: isOverridden(month, post.id) }); + rows.push({ + post, + figures, + overridden: isOverridden(month, post.id), + hasAllocationRule: ruleForPost(month, post, monthId) !== null, + }); } - const base = dataset.settings.baseCurrency; const baseDigits = dataset.settings.digits; + const looseCharges = looseChargesForMonth(dataset, monthId); const totalAllocation = roundMoney( rows.reduce((sum, r) => sum + r.figures.allocation, 0), baseDigits, ); const totalCharges = roundMoney( - rows.reduce((sum, r) => sum + r.figures.charges, 0), + rows.reduce((sum, r) => sum + r.figures.charges, looseCharges), baseDigits, ); @@ -76,8 +86,9 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { income, totalAllocation, totalCharges, + looseCharges, unallocated: roundMoney(income - totalAllocation, baseDigits), - overspentCount: rows.filter((r) => r.figures.remaining < 0).length, + overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length, rows, }; } @@ -86,6 +97,7 @@ export interface YearPostRow { post: Post; /** Twelve entries, January to December. */ byMonth: PostMonthFigures[]; + hasAllocationRuleByMonth: boolean[]; totalAllocation: number; totalCharges: number; /** December's remaining balance. */ @@ -98,22 +110,27 @@ export interface YearViewModel { incomeByMonth: number[]; totalIncome: number; totalCharges: number; + looseByMonth: number[]; + totalLooseCharges: number; rows: YearPostRow[]; } export function yearView(dataset: Dataset, year: number): YearViewModel { const months = monthsOfYear(year); const fold = foldBalances(dataset, months[11]!); - const base = dataset.settings.baseCurrency; const baseDigits = dataset.settings.digits; const incomeByMonth = months.map((m) => incomeFor(dataset, m)); + const looseByMonth = months.map((m) => looseChargesForMonth(dataset, m)); const rows: YearPostRow[] = visiblePosts(dataset).map((post) => { const byMonth = months.map((m) => figuresFor(fold, post.id, m)); return { post, byMonth, + hasAllocationRuleByMonth: months.map((monthId) => + ruleForPost(dataset.months.find((m) => m.id === monthId), post, monthId) !== null, + ), totalAllocation: roundMoney( byMonth.reduce((sum, f) => sum + f.allocation, 0), baseDigits, @@ -135,9 +152,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel { baseDigits, ), totalCharges: roundMoney( - rows.reduce((sum, r) => sum + r.totalCharges, 0), + rows.reduce((sum, r) => sum + r.totalCharges, looseByMonth.reduce((a, b) => a + b, 0)), baseDigits, ), + looseByMonth, + totalLooseCharges: roundMoney(looseByMonth.reduce((a, b) => a + b, 0), baseDigits), rows, }; } @@ -148,6 +167,7 @@ export interface SummaryViewModel { byPost: { post: Post; charges: number }[]; byMonth: { monthId: MonthId; charges: number }[]; totalCharges: number; + looseCharges: number; totalIncome: number; difference: number; } @@ -176,7 +196,7 @@ export function summaryView( charges: roundMoney( dataset.posts.reduce( (sum, p) => sum + figuresFor(fold, p.id, monthId).charges, - 0, + looseChargesForMonth(dataset, monthId), ), baseDigits, ), @@ -186,6 +206,10 @@ export function summaryView( byMonth.reduce((sum, m) => sum + m.charges, 0), baseDigits, ); + const looseCharges = roundMoney( + months.reduce((sum, monthId) => sum + looseChargesForMonth(dataset, monthId), 0), + baseDigits, + ); const totalIncome = roundMoney( months.reduce((sum, m) => sum + incomeFor(dataset, m), 0), baseDigits, @@ -197,6 +221,7 @@ export function summaryView( byPost, byMonth, totalCharges, + looseCharges, totalIncome, difference: roundMoney(totalIncome - totalCharges, baseDigits), }; diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 5bc497b..38b9097 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -53,6 +53,11 @@ describe("round trip", () => { }); describe("validation", () => { + test("accepts a purchase with no post and preserves it through export", () => { + const data = populated(); + data.purchases[0]!.splits = []; + expect(parseDatasetJson(exportDatasetJson(data)).purchases[0]!.splits).toEqual([]); + }); test("rejects malformed JSON", () => { expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError); }); @@ -89,12 +94,6 @@ describe("validation", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i); }); - test("rejects a purchase with no splits", () => { - const data = populated(); - data.purchases[0]!.splits = []; - expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i); - }); - test("rejects an unsupported currency", () => { const data = populated() as any; // CHF, not GBP: GBP is one of the currencies the app ships with now. diff --git a/src/export/json.ts b/src/export/json.ts index 6d92bc1..586c788 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -242,8 +242,8 @@ function requireRecurrence(recurrence: any, label: string): void { * `distributeByWeight`/`distributeByAmount` branch in `domain/charges.ts`. * One helper means the two write paths cannot silently drift into checking * to different standards; the two loops used to each carry their own copy of - * the "no splits" / "one absorber" / "unknown post" checks, so this replaces - * both rather than adding a third copy. + * the "one absorber" / "unknown post" checks, so this replaces both rather + * than adding a third copy. Bills require a split; purchases may be unassigned. * * `splitMode` must be one of the two known modes: `charges.ts` picks its * distribution function with `splitMode === "percent" ? ... : ...`, so any @@ -266,15 +266,17 @@ function requireSplits( splitMode: unknown, label: string, postIds: ReadonlySet, + allowEmpty = false, ): void { if (!SPLIT_MODES.has(splitMode as string)) { throw new ImportValidationError( `${label} has an unknown splitMode "${String(splitMode)}"`, ); } - if (!Array.isArray(splits) || splits.length === 0) { + if (!Array.isArray(splits) || (splits.length === 0 && !allowEmpty)) { throw new ImportValidationError(`${label} has no splits; at least one split is required`); } + if (splits.length === 0) return; const absorbers = (splits as Array>).filter( (s) => s?.absorbsRemainder, ).length; @@ -297,7 +299,7 @@ function requireSplits( /** * Validates far enough that the fold cannot throw on the imported data: every - * split points at a real post, every purchase has exactly one remainder + * split points at a real post, every assigned purchase has exactly one remainder * absorber, every MonthId parses, every rule version resolves unambiguously * to a number rather than to NaN or a missing rate, and every recurring * cost's startDate is both granular enough for its recurrence kind @@ -484,7 +486,7 @@ export function parseDatasetJson(text: string): Dataset { if (!PURCHASE_DATE.test(purchase.date)) { throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`); } - requireSplits(purchase.splits, purchase.splitMode, label, postIds); + requireSplits(purchase.splits, purchase.splitMode, label, postIds, true); for (const slice of purchase.schedule?.slices ?? []) { if (!MONTH_ID.test(slice.month)) { throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`); diff --git a/src/export/ods.test.ts b/src/export/ods.test.ts index d9af74c..5404ce7 100644 --- a/src/export/ods.test.ts +++ b/src/export/ods.test.ts @@ -25,6 +25,24 @@ function populated(): Dataset { } describe("buildWorkbook", () => { + test("exports an unassigned purchase as a row", () => { + const data = populated(); + data.purchases[0]!.splits = []; + const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!; + expect(purchases.rows).toHaveLength(2); + expect(purchases.rows[1]![3]).toEqual({ kind: "text", value: "Unassigned" }); + }); + + test("exports each unassigned purchase amount separately", () => { + const data = populated(); + data.purchases[0]!.splits = []; + data.purchases.push({ ...data.purchases[0]!, id: "p2", total: { amount: 50, currency: "DKK" } }); + const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!; + expect(purchases.rows.slice(1).map((row) => row[4])).toEqual([ + { kind: "number", value: 400 }, + { kind: "number", value: 50 }, + ]); + }); test("includes Summary, Months, Purchases, and one sheet per post", () => { const names = buildWorkbook(populated()).map((s) => s.name); expect(names.slice(0, 3)).toEqual(["Summary", "Months", "Purchases"]); diff --git a/src/export/ods.ts b/src/export/ods.ts index da67ff7..a97eacb 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -1,5 +1,6 @@ import { zipSync, strToU8 } from "fflate"; -import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts"; +import { chargesForPurchaseInMonth, looseChargesForMonth, sliceAmountForMonth } from "../domain/charges.ts"; +import { toBase } from "../domain/fx.ts"; import { monthRange } from "../domain/months.ts"; import { roundMoney } from "../domain/money.ts"; import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts"; @@ -35,6 +36,15 @@ function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMon ]); } + const loose = months.map((m) => looseChargesForMonth(dataset, m)); + if (loose.some((amount) => amount !== 0)) { + rows.push([ + text("Unassigned"), + ...loose.map(num), + num(roundMoney(loose.reduce((a, b) => a + b, 0), dataset.settings.digits)), + ]); + } + const summary = summaryView(dataset, months[0]!, months[months.length - 1]!); rows.push([ text("Total"), @@ -89,6 +99,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet { dataset.fxRates, dataset.settings.digits, ); + if (purchase.splits.length === 0) { + const amount = toBase(slice, base, dataset.fxRates, dataset.settings.digits); + rows.push([ + text(monthId), text(purchase.date), text(purchase.description), text("Unassigned"), + num(amount), num(slice.amount), text(slice.currency), text(purchase.schedule ? "yes" : "no"), + ]); + } for (const charge of charges) { rows.push([ text(monthId), diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 36e185f..7838ab3 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -312,6 +312,16 @@ describe("purchases", () => { expect(data.purchases[0]!.description).toBe("Dinner"); }); + test("an edited purchase can lose its post and bill association", () => { + const data = draft(); + const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id)); + const purchase = actions.confirmOccurrence(data, bill.id, "2026-09"); + actions.updatePurchase(data, purchase.id, { splits: [], source: undefined }); + expect(data.purchases[0]!.splits).toEqual([]); + expect(data.purchases[0]!.source).toBeUndefined(); + expect(monthView(data, "2026-09").looseCharges).toBe(30); + }); + test("updatePurchase rounds the total amount, split values, and schedule slice amounts", () => { const data = draft(); const postId = data.posts[0]!.id; diff --git a/src/ui/bulkEntry.test.ts b/src/ui/bulkEntry.test.ts index d3dd6b4..a5363b8 100644 --- a/src/ui/bulkEntry.test.ts +++ b/src/ui/bulkEntry.test.ts @@ -76,6 +76,11 @@ describe("lineToDraft", () => { }); describe("validateBulk", () => { + test("allows lines with no post", () => { + const loose = draft({ postId: "", lines: [line("Milk", 25)] }); + expect(validateBulk(loose)).toEqual([]); + expect(toPurchases(loose)[0]!.splits).toEqual([]); + }); test("a well-formed batch has no errors", () => { expect(validateBulk(draft({ lines: [line("Milk", 25), line("Bread", 30)] }))).toEqual([]); }); @@ -98,12 +103,6 @@ describe("validateBulk", () => { expect(errors.some((e) => e.includes("1") && /description/i.test(e))).toBe(true); }); - test("requires a post", () => { - expect(validateBulk(draft({ postId: "", lines: [line("Milk", 25)] }))).toContain( - "Choose a post for these lines.", - ); - }); - test("does NOT reject a negative amount — over-budget and refunds are allowed", () => { expect(validateBulk(draft({ lines: [line("Refund", -25)] }))).toEqual([]); }); diff --git a/src/ui/bulkEntry.ts b/src/ui/bulkEntry.ts index 690c8fc..923800f 100644 --- a/src/ui/bulkEntry.ts +++ b/src/ui/bulkEntry.ts @@ -8,8 +8,9 @@ import { toPurchase, type PurchaseDraft } from "./purchaseForm.ts"; * * Date, post and currency are shared across the batch and set once; a line is * just a description and an amount. Each line becomes its own Purchase landing - * wholly in the shared post, built through the SAME `toPurchase` the single - * form uses, so there is one definition of what a purchase is. + * wholly in the shared post, or unassigned when no post is selected. The + * SAME `toPurchase` the single form uses builds it, so there is one definition + * of what a purchase is. */ export interface BulkLine { @@ -85,7 +86,7 @@ export function lineToDraft(draft: BulkDraft, line: BulkLine): PurchaseDraft { amount: line.amount, currency: draft.currency, splitMode: "percent", - splits: [{ postId: draft.postId, value: 100, absorbsRemainder: true }], + splits: draft.postId ? [{ postId: draft.postId, value: 100, absorbsRemainder: true }] : [], plan: null, }; } @@ -99,9 +100,6 @@ export function validateBulk(draft: BulkDraft): string[] { const errors: string[] = []; const filled = filledLines(draft); - if (draft.postId === "") { - errors.push("Choose a post for these lines."); - } if (filled.length === 0) { errors.push("Add at least one line."); } diff --git a/src/ui/components/PlanEditor.test.tsx b/src/ui/components/PlanEditor.test.tsx new file mode 100644 index 0000000..859b5d4 --- /dev/null +++ b/src/ui/components/PlanEditor.test.tsx @@ -0,0 +1,48 @@ +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 { expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { store } from "../../store/index.ts"; +import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts"; +import { PlanEditor } from "./PlanEditor.tsx"; + +test("a purchase dated by month can start a finance plan in that month", () => { + const original = { get: store.get, subscribe: store.subscribe }; + const dataset = createSeedDataset("2026-09"); + store.get = () => dataset; + store.subscribe = () => () => {}; + const container = document.createElement("div"); + const root = createRoot(container); + let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 }; + const render = () => root.render( { draft = next; render(); }} />); + try { + act(render); + const button = container.querySelector("button")!; + expect(button.disabled).toBe(false); + act(() => button.click()); + expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]); + } finally { + act(() => root.unmount()); + Object.assign(store, original); + } +}); + +test("an incomplete purchase date cannot start a finance plan", () => { + const original = { get: store.get, subscribe: store.subscribe }; + const dataset = createSeedDataset("2026-09"); + store.get = () => dataset; + store.subscribe = () => () => {}; + const container = document.createElement("div"); + const root = createRoot(container); + try { + act(() => root.render( {}} />)); + expect(container.querySelector("button")!.disabled).toBe(true); + } finally { + act(() => root.unmount()); + Object.assign(store, original); + } +}); diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx index 92201c6..2eb18ee 100644 --- a/src/ui/components/PlanEditor.tsx +++ b/src/ui/components/PlanEditor.tsx @@ -17,15 +17,13 @@ interface Props { onChange: (next: PurchaseDraft) => void; } -// A native reports "" until the user has filled in every -// field, and monthOf() throws on anything that isn't a well-formed -// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the -// domain is right to throw on malformed input; the caller just must not call -// it with malformed input. -const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; - function hasValidDate(draft: PurchaseDraft): boolean { - return ISO_DATE_PATTERN.test(draft.date); + try { + monthOf(draft.date); + return true; + } catch { + return false; + } } export function PlanEditor({ draft, onChange }: Props) { @@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) { {!dateReady && (

    - Pick a date for this purchase first. + Pick a month or date for this purchase first.

    )} diff --git a/src/ui/components/PostTable.test.tsx b/src/ui/components/PostTable.test.tsx new file mode 100644 index 0000000..db82986 --- /dev/null +++ b/src/ui/components/PostTable.test.tsx @@ -0,0 +1,43 @@ +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 { expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { monthView } from "../../domain/views.ts"; +import { store } from "../../store/index.ts"; +import { PostTable } from "./PostTable.tsx"; + +test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => { + const data = createSeedDataset("2026-09"); + const [unbudgeted, zero] = data.posts; + unbudgeted!.name = "Unbudgeted"; + unbudgeted!.rules = []; + zero!.name = "Zero rule"; + zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }]; + data.purchases = [unbudgeted!, zero!].map((post, i) => ({ + id: `p${i}`, date: "2026-09", description: post.name, + total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const, + splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null, + })); + const original = { get: store.get, subscribe: store.subscribe }; + store.get = () => data; + store.subscribe = () => () => {}; + const container = document.createElement("div"); + const root = createRoot(container); + try { + act(() => root.render()); + const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!; + const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!; + expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—"); + expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false); + expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0"); + expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true); + } finally { + act(() => root.unmount()); + Object.assign(store, original); + } +}); diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 34d5477..1b6114c 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -50,21 +50,21 @@ interface RowCells { * plain function, not a component, and both table markups call it from inside * a map. */ -function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells { +function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells { return { carriedIn: fmt.signedAmount(figures.carriedIn), carriedInTone: figures.carriedIn > 0 ? "text-surplus" - : figures.carriedIn < 0 + : hasAllocationRule && figures.carriedIn < 0 ? "text-overspend" : "", - allocation: fmt.amount(figures.allocation), + allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—", charges: fmt.amount(figures.charges), remaining: fmt.signedAmount(figures.remaining), - remainingTone: figures.remaining < 0 ? "text-overspend" : "", + remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "", projected: fmt.signedAmount(figures.projected), - segments: carryMeterSegments(figures), + segments: carryMeterSegments(figures, hasAllocationRule), }; } diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index ec4a814..884e436 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -49,6 +49,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { .sort((a, b) => a.order - b.order); const [open, setOpen] = useState(false); + const [detachBill, setDetachBill] = useState(false); const [draft, setDraft] = useState(() => purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); @@ -68,6 +69,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft); function reset() { + setDetachBill(false); setDraft( purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); @@ -86,7 +88,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const created = bulkMode ? toPurchases(bulk) : null; void mutate((data) => { if (created) for (const p of created) addPurchase(data, p); - else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft)); + else if (purchase) updatePurchase(data, purchase.id, { + ...toPurchase(draft), + ...(detachBill ? { source: undefined } : {}), + }); else addPurchase(data, toPurchase(draft)); }).then((saved) => { if (!saved) return; @@ -166,7 +171,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setBulk((b) => ({ ...b, postId })); }} > - + {activePosts.map((post) => (