diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4e24da1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,96 @@ +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: . + build-args: | + BUILD_VERSION=${{ gitea.sha }} + BUILD_REPOSITORY=https://gitea.lucaswinther.info/${{ gitea.repository }} + 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 --name budget2-ci "$image" + trap 'docker stop budget2-ci' EXIT + 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}`); + } + ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9261a06..e771ec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: docker build -t budget2 . + - run: docker build --build-arg BUILD_VERSION=${{ github.sha }} --build-arg BUILD_REPOSITORY=https://github.com/${{ github.repository }} -t budget2 . - name: Serve the image and check it responds run: | docker run -d --rm -p 3000:3000 --name budget2 budget2 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 9acb05f..5b8fd77 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -28,6 +28,7 @@ jobs: env: BUN_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}/ BUILD_VERSION: ${{ github.sha }} + BUILD_REPOSITORY: https://github.com/${{ github.repository }} run: bun run build - name: Stop Pages from running the output through Jekyll run: touch dist/.nojekyll diff --git a/AGENTS.md b/AGENTS.md index d3caa8d..4395ebc 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,8 +142,8 @@ ship twice — re-introducing one is a regression, not a style choice. way to undo it from the UI. The rule itself is stated ONCE, in `src/domain/granularity.ts` (`startDateNeedsDay`, `chargeDateNeedsDay`); both boundaries, the purchase dialog's helpers and the Settings recurring - table all call it rather than restating it, and the 7 -> 8 - migration expands the month-only charge dates schema 7 legitimately holds so + table all call it rather than restating it, and the 10 -> 11 + migration expands the month-only charge dates earlier schemas legitimately hold so that the migration's output satisfies the import boundary. → `src/domain/granularity.test.ts`, `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`, `src/store/migrations.test.ts`, diff --git a/Dockerfile b/Dockerfile index bc8a24e..ae089de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . ARG BUN_PUBLIC_BASE_PATH=/ +ARG BUILD_VERSION +ARG BUILD_REPOSITORY ENV BUN_PUBLIC_BASE_PATH=$BUN_PUBLIC_BASE_PATH RUN bun run build diff --git a/README.md b/README.md index 380805c..81c4a70 100755 --- a/README.md +++ b/README.md @@ -15,15 +15,35 @@ 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 build -t budget2 . +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 --build-arg BUILD_VERSION="$(git rev-parse HEAD)" --build-arg BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/budget2.0 -t budget2 . docker run --rm -p 3000:3000 budget2 ``` To serve from a subpath, build and run with `BUN_PUBLIC_BASE_PATH`: ```bash -docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ . +docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ --build-arg BUILD_VERSION="$(git rev-parse HEAD)" --build-arg BUILD_REPOSITORY=https://gitea.lucaswinther.info/lucasw89/budget2.0 . docker run --rm -p 3000:3000 -e BUN_PUBLIC_BASE_PATH=/budget/ budget2 ``` @@ -39,10 +59,16 @@ Everything is stored in this browser's IndexedDB. **Export regularly** from Settings → Your data: the JSON export is both your backup and how you move your budget to another device. +Use the header's Undo and Redo controls to reverse saved edits in the current +session. Ctrl/Cmd+Z undoes; Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes. While typing +in a field, those shortcuts use the browser's text undo. History holds up to +50 edits and disappears when the page reloads. + Both destructive actions there — Import, and "Reset everything" — download a backup of your current data before replacing it, and abort if that download fails. Reset puts the app back to a brand-new install: the three starter posts, the DKK/USD/EUR currency table, this month at zero income, no purchases. +Import and reset clear session undo history after they succeed. ## Known limitations diff --git a/build.ts b/build.ts index 196fe2c..f97d107 100644 --- a/build.ts +++ b/build.ts @@ -10,7 +10,31 @@ import type { Currency, FxRate } from "./src/domain/types.ts"; const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); const outdir = path.join(process.cwd(), "dist"); -const version = process.env.BUILD_VERSION ?? String(Date.now()); +// A checkout knows its own revision. Docker excludes .git, so CI passes the +// same value through BUILD_VERSION as a build argument instead. +let checkoutCommit: string | undefined; +if (!process.env.BUILD_VERSION) { + try { + const candidate = (await Bun.$`git rev-parse --verify HEAD`.quiet().text()).trim(); + if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(candidate)) { + const changes = (await Bun.$`git status --porcelain --untracked-files=normal`.quiet().text()).trim(); + checkoutCommit = changes ? `${candidate}-dirty` : candidate; + } + } catch { + // Source archives and Docker contexts may have no Git metadata. + } +} +const version = process.env.BUILD_VERSION || checkoutCommit || String(Date.now()); +let checkoutRepository: string | undefined; +if (!process.env.BUILD_REPOSITORY) { + try { + const candidate = (await Bun.$`git remote get-url origin`.quiet().text()).trim(); + if (/^https?:\/\//.test(candidate)) checkoutRepository = candidate.replace(/\.git\/?$/, "").replace(/\/$/, ""); + } catch { + // Source archives and Docker contexts may have no remote. + } +} +const sourceRepository = process.env.BUILD_REPOSITORY || checkoutRepository || "https://github.com/StereotypicalCat/budget2.0"; const FX_BASE: Currency = "DKK"; const FX_TARGETS: Currency[] = ["USD", "EUR", "GBP"]; @@ -108,6 +132,7 @@ const result = await Bun.build({ // build a bug report is about. Only defined here, never in dev, which is // why it is read through a try/catch accessor. "process.env.BUN_PUBLIC_BUILD_VERSION": JSON.stringify(version), + "process.env.BUN_PUBLIC_SOURCE_REPOSITORY": JSON.stringify(sourceRepository), // Double-encoded on purpose: the inlined literal must be a JSON *string* // for parseBakedRates to parse at runtime. "process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1eac6e6..7ebd42c 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,8 +58,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: @@ -117,6 +118,21 @@ uses on a first run, so "reset" and "a brand-new browser" cannot drift apart. Both are preceded in the UI by a JSON backup download that must succeed before the destructive write is attempted. +## Session undo and redo + +The snapshot store keeps up to 50 prior saved datasets in memory. A changed +`mutate` adds one step after its write succeeds; an unchanged mutation adds +none. Undo and redo run through the same queue and persist their target dataset +before changing the visible snapshot or either history stack. A failed write +therefore leaves both the data and the available undo/redo actions intact. + +Import and reset clear the stacks only after their write succeeds. They are +whole-dataset replacement flows with backup-first protection, not ordinary +undoable edits. History is not in `Dataset` or IndexedDB and vanishes on reload. +The header reads a stable history capability snapshot from the store to enable +its controls; keyboard shortcuts skip editable fields so browser text undo +still works. + ## Money A float, deliberately, with two rules that make it safe: @@ -157,6 +173,20 @@ 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. In the month Budget tree, +they appear in the permanent, derived Unassigned section for each month their +purchase or finance plan touches. Creating a post from one assigns its full +amount to the new post without changing the purchase total or schedule. + +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: @@ -220,6 +250,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 823a87c..4f3e04e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ record. no household mode. → [`PRODUCT.md`](PRODUCT.md) - **Schema migrations are mandatory, not optional.** `schemaVersion` plus ordered migration functions applied on load, because IndexedDB holds the - user's only copy of their financial data. Now at version 8. + user's only copy of their financial data. Now at version 11. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **A destructive write downloads a backup first.** Import and reset both export before replacing, and abort if the export fails. → commit `ac3d18a` @@ -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 @@ -235,8 +243,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/PRODUCT.md b/docs/PRODUCT.md index 90739e2..c0395e5 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -54,12 +54,22 @@ Money is handled in DKK, USD and EUR, with DKK as the base. Confirmed and in place: +- Nested budget folders organize posts, bills, and optionally individual + purchases. A single-post bill starts beneath that post but can be moved + independently. Folder Bills due reports scheduled bill prices for the selected + month; posts alone hold envelope balances. A purchase's separate row never + changes which posts its splits charge. - 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/docs/TODO.md b/docs/TODO.md index 66025db..edb4ba2 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,24 +8,29 @@ 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`. 758 tests passing, -`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and -offline paths. +**Current state:** the anchored model is merged with budget folders and +placement. 870 tests passing across 61 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 8.** v1 -> v2 made allocation rules a dated series; +**Schema is at version 11.** 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`; v7 -> v8 moves a `lastCharge` cost off slots — it -strips `occurrenceDate` from every anchored charge, expands a month-only -`startDate` on an anchored `everyNMonths` cost to a day-granular one, and -expands a month-only `Purchase.date` on every anchored charge (an anchored -cost has no slots, so its charges' own dates are what the next due date is -measured from; the day comes from the `occurrenceDate` being stripped when -that names one in the same month, otherwise the 1st). Unlike the first six, -this step is NOT behaviour-preserving by construction: an anchored cost's -projected due dates genuinely change shape, because they no longer come from a -grid at all. It is still tested, the same as the others. +adds `Dataset.recurring`; v7 -> v8 adds budget folders and assigns existing +posts and recurring bills a deterministic mixed sibling order under Unfiled. +v8 -> v9 adds optional purchase and bill placement fields without moving old rows; +v9 -> v10 nests existing unfiled single-post bills under their post. +v10 -> v11 moves a `lastCharge` cost off slots — it strips `occurrenceDate` +from every anchored charge, expands a month-only `startDate` on an anchored +`everyNMonths` cost to a day-granular one, and expands a month-only +`Purchase.date` on every anchored charge (the day comes from the +`occurrenceDate` being stripped when that names one in the same month, +otherwise the 1st). The first nine preserve financial behaviour — not one +changes a stored figure. The tenth changes no stored figure either, but it is +NOT behaviour-preserving by construction: an anchored cost's projected due +dates genuinely change shape, because they no longer come from a grid at all. +All ten are tested. A migration applies **on load, in memory**; it is written back on the next mutation, not by `load()` itself. So a freshly migrated dataset shows the new @@ -35,7 +40,36 @@ IndexedDB instead of the app. --- -## 1. Two things the recurring-cost model gets wrong, both known +## Optional monthly income forecast + +Add a user-controlled toggle that predicts a month's income from earlier months. +For each earlier month, divide its recorded income by that month's number of +workdays; average those per-workday figures, then multiply by the target month's +workdays. Let the owner adjust the prediction when income changes, for example +after a raise, without rewriting recorded income or earlier months. Show +predicted and entered income distinctly, and keep budgeting based on entered +income unless the owner explicitly chooses otherwise. Before implementation, +settle whether workdays mean Monday–Friday only or exclude local holidays, and +whether an adjustment applies to one month or all future predictions. + +## 1. Folder rename blur during an in-flight save + +A folder name draft can remain unsaved if the owner changes it while a rename +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. Two things the recurring-cost model gets wrong, both known Neither is a bug introduced by the anchored model. Both are recorded here because they were found while reviewing it, and both are the kind of thing a @@ -86,7 +120,7 @@ This follows from the spec and is not a defect, but it sits in tension with AGENTS.md §1's "archiving must not retroactively remove past expected charges", so it deserves saying out loud. -`anchoredOccurrences` emits occurrences from ONE anchor: the latest charge. +The anchored walk emits occurrences from ONE anchor: the latest charge. Recording a charge therefore erases every earlier month's expectation for that cost — there is nothing left in the dataset that says a charge was once expected in July. Consequences, all by design: @@ -114,7 +148,7 @@ slots stay put whether they were ever settled or not. The two anchorings differ here on purpose (AGENTS.md §1's schedule invariant), and unifying them would reintroduce the bug the anchored model exists to fix. -## 2. Visual redesign, sub-project 3 — Year, Summary, Post detail +## 4. 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 @@ -154,7 +188,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. -## 3. Visual redesign, sub-project 4 — dialogs and fast entry +## 5. Visual redesign, sub-project 4 — dialogs and fast entry Untouched. 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 0690c38..8fc96a5 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts"; import { ruleAt } from "../../domain/allocation.ts"; @@ -50,21 +51,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), }; } @@ -76,7 +77,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. @@ -173,7 +174,6 @@ function ChangeRuleButton({ } export function PostTable({ monthId, rows, onChangeRule }: Props) { - const fmt = useMoneyFormat(); return ( <>
@@ -194,11 +194,38 @@ 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; childrenExpanded?: boolean; onToggleChildren?: () => void; } + +function PostChildrenToggle({ postName, expanded, onToggle }: { postName: string; expanded: boolean; onToggle: () => void }) { + const Icon = expanded ? ChevronDown : ChevronRight; + return ; +} + +export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) { + const cells = postRowCells(row, useMoneyFormat()); + return ( + - + + {onToggleChildren && } {onChangeRule && ( - ); - })} - - + ); +} -
    - {rows.map((row) => { - const cells = postRowCells(row, fmt); - return ( -
  • +export function MobilePostRow({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) { + const cells = postRowCells(row, useMoneyFormat()); + return ( +
  • + {onToggleChildren && }
  • - ); - })} -
- ); } diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index df4b95f..1479690 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -35,6 +35,8 @@ import { addPurchase, addRecurringCost, cancelScheduleFrom, + placePurchase, + placeRecurringUnderPost, updatePurchase, } from "../../store/actions.ts"; import { @@ -62,8 +64,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const activePosts = dataset.posts .filter((p) => !p.archived) .sort((a, b) => a.order - b.order); + const editablePosts = dataset.posts + .filter((p) => !p.archived || purchase?.splits.some(split => split.postId === p.id)) + .sort((a, b) => a.order - b.order); const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState(() => purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger"); const [draft, setDraft] = useState(() => purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); @@ -107,6 +113,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { : offerable; function reset() { + setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger"); setDraft( purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); @@ -142,6 +149,9 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { data, seedCostFromDraft(draft, newRecurrence, newAnchoring), ); + // Nested under its post when it has exactly one, the same default a + // bill created in Settings gets. + if (seeded.splits.length === 1) placeRecurringUnderPost(data, seeded.id, seeded.splits[0]!.postId); source = sourceFor(data, seeded, draft.date); } else if (recurringSelection !== "none") { const cost = data.recurring.find((c) => c.id === recurringSelection); @@ -165,6 +175,8 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { // when the owner picks "None" would leave a stored association // untouched instead of clearing it. updatePurchase(data, purchase.id, { ...toPurchase(draft), source }); + const destination = draft.splits.length === 0 || placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement; + if (destination !== purchase.placement?.folderId || (destination === undefined) !== (purchase.placement === undefined)) placePurchase(data, purchase.id, destination); } else { addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) }); } @@ -246,7 +258,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setBulk((b) => ({ ...b, postId })); }} > - + {activePosts.map((post) => (