fix: nest legacy bills and stamp builds with commit
This commit is contained in:
@@ -59,6 +59,9 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ Gitea account or token that has package read permission.
|
||||
To build locally instead:
|
||||
|
||||
```bash
|
||||
docker build -t budget2 .
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
+5
-4
@@ -10,18 +10,19 @@ task moved to `AGENTS.md`.
|
||||
|
||||
**Current feature worktree state:** budget folders and recurring cost placement
|
||||
are implemented.
|
||||
746 tests passing across 59 files. `bunx tsc --noEmit` is clean;
|
||||
753 tests passing across 59 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 9.** v1 -> v2 made allocation rules a dated series;
|
||||
**Schema is at version 10.** 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 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.
|
||||
All eight are behaviour-preserving by construction —
|
||||
v8 -> v9 adds optional purchase and bill placement fields without moving old rows;
|
||||
v9 -> v10 nests existing unfiled single-post bills under their post.
|
||||
All nine preserve financial behaviour —
|
||||
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
|
||||
|
||||
@@ -6,4 +6,6 @@ The owner can edit a purchase's post splits and also give it an independent row
|
||||
|
||||
A new recurring cost assigned to one post defaults to a row nested beneath that post. A dedicated post and its bill are nested when created together. A bill split across several posts remains at folder level. An explicit folder choice in the add form takes precedence. `RecurringCost.parentPostId` records the nested display parent, while its splits remain the financial destinations. A nested bill follows the post when the post moves. Settings can move the bill to another post or to a folder without changing its splits or confirmations.
|
||||
|
||||
Schema 9 adds these optional fields. The v8 to v9 migration only advances the version; existing purchases and bills retain their locations. JSON import checks placement references, sibling order, and parent post references. Folder Bills due includes nested bills once. Purchase rows do not contribute to Bills due, since the figure is a schedule total.
|
||||
Posts with nested bills have a disclosure control on the month page. They start collapsed, like folders, and expanding the post reveals its bills beneath the balance row. The post balance remains visible in either state.
|
||||
|
||||
Schema 9 adds these optional fields. Its v8 to v9 migration only advances the version. Schema 10 then nests existing unfiled, single-post bills beneath their destination posts, so a preexisting budget gets the same layout as new bills. It leaves splits and confirmations unchanged. JSON import checks placement references, sibling order, and parent post references. Folder Bills due includes nested bills once. Purchase rows do not contribute to Bills due, since the figure is a schedule total.
|
||||
|
||||
+1
-1
@@ -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 = 9;
|
||||
export const SCHEMA_VERSION = 10;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { test, expect, describe } from "bun:test";
|
||||
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
|
||||
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
|
||||
import { figuresFor, foldBalances } from "../domain/fold.ts";
|
||||
import { parseDatasetJson } from "../export/json.ts";
|
||||
import { budgetTree } from "../domain/budgetTree.ts";
|
||||
import { monthView } from "../domain/views.ts";
|
||||
|
||||
describe("migrate", () => {
|
||||
test("passes a current-version dataset through unchanged", () => {
|
||||
@@ -507,22 +510,77 @@ test("v7 migration preserves records and assigns mixed order", () => {
|
||||
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(migrated.recurring.map((bill) => [bill.id, bill.order, bill.folderId, bill.parentPostId])).toEqual([
|
||||
["bill-later", 0, null, "first"], ["bill-first", 0, null, "later"],
|
||||
]);
|
||||
expect(new Set([...migrated.posts, ...migrated.recurring].map((item) => item.order)).size).toBe(4);
|
||||
expect(new Set(migrated.posts.map((item) => item.order)).size).toBe(2);
|
||||
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);
|
||||
});
|
||||
|
||||
test("v8 migration preserves existing purchase and bill locations", () => {
|
||||
test("v8 migration preserves existing purchase and post data", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.settings.schemaVersion = 8;
|
||||
const before = structuredClone(data);
|
||||
const migrated = migrate(data);
|
||||
expect(migrated.settings.schemaVersion).toBe(9);
|
||||
expect(migrated.settings.schemaVersion).toBe(10);
|
||||
expect(migrated.posts).toEqual(before.posts);
|
||||
expect(migrated.purchases).toEqual(before.purchases);
|
||||
expect(migrated.recurring).toEqual(before.recurring);
|
||||
});
|
||||
|
||||
for (const oldVersion of [8, 9]) test(`v${oldVersion} single-post unfiled bills become children of their posts`, () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.settings.schemaVersion = oldVersion;
|
||||
const games = data.posts[0]!;
|
||||
data.recurring = ["Star Rail Pass", "Genshin Pass"].map((name, index) => ({
|
||||
id: `bill-${index}`, name, order: 3 + index, folderId: null, archived: false,
|
||||
amount: { amount: 35, currency: "DKK" }, startDate: "2026-10",
|
||||
recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const,
|
||||
splitMode: "percent" as const,
|
||||
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
|
||||
}));
|
||||
const migrated = migrate(data);
|
||||
expect(migrated.recurring.map(bill => [bill.name, bill.parentPostId, bill.order])).toEqual([
|
||||
["Star Rail Pass", games.id, 0], ["Genshin Pass", games.id, 1],
|
||||
]);
|
||||
expect(migrated.recurring.every(bill => bill.folderId === null)).toBe(true);
|
||||
const imported = parseDatasetJson(JSON.stringify(data));
|
||||
const gamesRow = budgetTree(imported, "2026-10", monthView(imported, "2026-10").rows)
|
||||
.unfiled.find(node => node.kind === "post" && node.row.post.id === games.id);
|
||||
expect(gamesRow?.kind).toBe("post");
|
||||
if (gamesRow?.kind === "post") expect(gamesRow.children.map(node => node.kind === "bill" ? node.cost.name : "other"))
|
||||
.toEqual(["Star Rail Pass", "Genshin Pass"]);
|
||||
});
|
||||
|
||||
test("v9 migration keeps explicitly filed and multi-post bills where they are", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.settings.schemaVersion = 9;
|
||||
data.folders = [{ id: "filed", name: "Bills", parentId: null, order: 3 }];
|
||||
const base = { archived: false, amount: { amount: 20, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const, splitMode: "percent" as const };
|
||||
data.recurring = [
|
||||
{ ...base, id: "filed-bill", name: "Filed", folderId: "filed", order: 0, splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] },
|
||||
{ ...base, id: "shared-bill", name: "Shared", folderId: null, order: 4, splits: [{ postId: data.posts[0]!.id, value: 50, absorbsRemainder: true }, { postId: data.posts[1]!.id, value: 50, absorbsRemainder: false }] },
|
||||
];
|
||||
const migrated = migrate(data);
|
||||
expect(migrated.recurring.map(bill => [bill.parentPostId, bill.folderId, bill.order])).toEqual([
|
||||
[undefined, "filed", 0], [undefined, null, 4],
|
||||
]);
|
||||
});
|
||||
|
||||
test("v9 migration appends old flat bills after already nested bills", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.settings.schemaVersion = 9;
|
||||
const postId = data.posts[0]!.id;
|
||||
const base = { archived: false, folderId: null, amount: { amount: 35, 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 }] };
|
||||
data.recurring = [
|
||||
{ ...base, id: "new", name: "Already nested", order: 0, parentPostId: postId },
|
||||
{ ...base, id: "old", name: "Old flat", order: 3 },
|
||||
];
|
||||
const migrated = migrate(data);
|
||||
expect(migrated.recurring.map(bill => [bill.parentPostId, bill.order])).toEqual([
|
||||
[postId, 0], [postId, 1],
|
||||
]);
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -216,6 +216,30 @@ const MIGRATIONS: Array<(data: any) => any> = [
|
||||
},
|
||||
// 8 -> 9: placement fields are optional, so existing rows stay where they are.
|
||||
(data: any) => ({ ...data, settings: { ...data.settings, schemaVersion: 9 } }),
|
||||
|
||||
// 9 -> 10: existing unfiled bills with one destination gain the same default
|
||||
// nesting as newly created bills. Splits and occurrence identity are untouched.
|
||||
(data: any) => {
|
||||
const postIds = new Set((data.posts ?? []).map((post: any) => post.id));
|
||||
const nextOrder = new Map<string, number>();
|
||||
const recurring = [...(data.recurring ?? [])].sort((a: any, b: any) => a.order - b.order);
|
||||
for (const bill of recurring) {
|
||||
if (bill.parentPostId) nextOrder.set(bill.parentPostId, Math.max(nextOrder.get(bill.parentPostId) ?? 0, bill.order + 1));
|
||||
}
|
||||
const nested = new Map(recurring.map((bill: any) => {
|
||||
const postId = bill.folderId === null && !bill.parentPostId && bill.splits?.length === 1
|
||||
? bill.splits[0].postId : undefined;
|
||||
if (!postId || !postIds.has(postId)) return [bill, bill];
|
||||
const order = nextOrder.get(postId) ?? 0;
|
||||
nextOrder.set(postId, order + 1);
|
||||
return [bill, { ...bill, parentPostId: postId, order }];
|
||||
}));
|
||||
return {
|
||||
...data,
|
||||
settings: { ...data.settings, schemaVersion: 10 },
|
||||
recurring: (data.recurring ?? []).map((bill: any) => nested.get(bill)),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
export function migrate(raw: unknown): Dataset {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
|
||||
import { commitHref, describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
|
||||
|
||||
test("a full commit SHA links to the exact source revision", () => {
|
||||
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4"))
|
||||
.toBe("https://github.com/StereotypicalCat/budget2.0/commit/61e7093c36f9a0111a5f1c8776aba4894c88ebc4");
|
||||
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4", "https://gitea.lucaswinther.info/lucasw89/budget2.0"))
|
||||
.toBe("https://gitea.lucaswinther.info/lucasw89/budget2.0/commit/61e7093c36f9a0111a5f1c8776aba4894c88ebc4");
|
||||
expect(commitHref("1788307200000")).toBeUndefined();
|
||||
expect(commitHref("v1.4.0")).toBeUndefined();
|
||||
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4-dirty")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an absent stamp reads as development, not as a blank or a lie", () => {
|
||||
expect(describeBuild(undefined)).toBe("development");
|
||||
@@ -20,6 +30,11 @@ test("a commit sha is shortened the way git shortens one", () => {
|
||||
expect(describeBuild("61e7093")).toBe("61e7093");
|
||||
});
|
||||
|
||||
test("a local build with uncommitted changes is marked as such", () => {
|
||||
expect(describeBuild("61e7093c36f9a0111a5f1c8776aba4894c88ebc4-dirty"))
|
||||
.toBe("61e7093-dirty");
|
||||
});
|
||||
|
||||
test("a version someone chose is printed as given", () => {
|
||||
// A sha, a tag and a release number must survive untouched: guessing at a
|
||||
// format the builder did not ask for is worse than showing their string.
|
||||
|
||||
+23
-2
@@ -20,7 +20,7 @@ export function readBuildVersionEnv(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** A millisecond epoch, which is what build.ts falls back to. */
|
||||
/** A millisecond epoch, used when no Git revision was supplied or available. */
|
||||
const EPOCH_MS = /^\d{10,}$/;
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ const LONG_SHA = /^(?=.*[a-f])[0-9a-f]{12,}$/i;
|
||||
* `BUILD_VERSION` may be anything the person building chose — a git sha, a tag,
|
||||
* a release number — so an unrecognised value is printed as given rather than
|
||||
* reformatted into something it is not. The one shape worth translating is
|
||||
* build.ts's own default, a `Date.now()` timestamp, because "1756809600000"
|
||||
* build.ts's source-archive fallback, a `Date.now()` timestamp, because "1756809600000"
|
||||
* tells a reader nothing and "2026-09-02" tells them what they wanted to know.
|
||||
*
|
||||
* ISO rather than a locale format: this string appears in bug reports against
|
||||
@@ -46,8 +46,29 @@ export function describeBuild(raw: string | undefined): string {
|
||||
const at = new Date(Number(raw));
|
||||
if (!Number.isNaN(at.getTime())) return at.toISOString().slice(0, 10);
|
||||
}
|
||||
if (LONG_SHA.test(raw.replace(/-dirty$/, "")) && raw.endsWith("-dirty")) return `${raw.slice(0, 7)}-dirty`;
|
||||
if (LONG_SHA.test(raw)) return raw.slice(0, 7);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export const BUILD_VERSION = describeBuild(readBuildVersionEnv());
|
||||
|
||||
/** The build injects its source origin; dev mode uses the public mirror. */
|
||||
export function readSourceRepositoryEnv(): string | undefined {
|
||||
try {
|
||||
return process.env.BUN_PUBLIC_SOURCE_REPOSITORY;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const SOURCE_REPOSITORY = readSourceRepositoryEnv() ?? "https://github.com/StereotypicalCat/budget2.0";
|
||||
|
||||
/** A build with an exact revision can link directly to its source. */
|
||||
export function commitHref(raw: string | undefined, repository = SOURCE_REPOSITORY): string | undefined {
|
||||
return raw && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw)
|
||||
? `${repository}/commit/${raw}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const COMMIT_HREF = commitHref(readBuildVersionEnv());
|
||||
|
||||
@@ -6,7 +6,7 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { addBudgetFolder, addRecurringCost, confirmOccurrence } from "../../store/actions.ts";
|
||||
import { addBudgetFolder, addRecurringCost, confirmOccurrence, placeRecurringUnderPost } from "../../store/actions.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { PostTableLegend } from "./PostTable.tsx";
|
||||
@@ -57,3 +57,27 @@ test("legend scopes base currency to post figures and identifies bill currencies
|
||||
expect(legend.textContent).toContain("Projected subtracts bills not yet confirmed.");
|
||||
expect(legend.textContent).not.toContain("All figures");
|
||||
}));
|
||||
|
||||
test("bills beneath a post are hidden until its post is expanded", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const games = data.posts[0]!;
|
||||
const bill = addRecurringCost(data, { name: "Star Rail Pass", folderId: null, archived: false, amount: { amount: 35, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: games.id, value: 100, absorbsRemainder: true }] });
|
||||
placeRecurringUnderPost(data, bill.id, games.id);
|
||||
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(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
|
||||
const controls = Array.from(container.querySelectorAll(`button[aria-label="Bills under ${games.name}"]`)) as HTMLButtonElement[];
|
||||
expect(controls).toHaveLength(2);
|
||||
expect(controls[0]!.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.querySelector(`[data-budget-item="${bill.id}"]`)).toBeNull();
|
||||
act(() => controls[0]!.click());
|
||||
expect(controls[0]!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.querySelectorAll(`[data-budget-item="${bill.id}"]`)).toHaveLength(2);
|
||||
} finally {
|
||||
act(() => root.unmount()); container.remove(); Object.assign(store, original);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,7 +37,10 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId;
|
||||
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.kind === "purchase" ? node.purchase.id : node.cost.id;
|
||||
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />, ...render(node.children, mobile, depth + 1)];
|
||||
if (node.kind === "post") {
|
||||
const rowProps = { row: node.row, indent, monthId, onChangeRule, ...(node.children.length ? { childrenExpanded: expanded.has(id), onToggleChildren: () => toggle(id) } : {}) };
|
||||
return [mobile ? <MobilePostRow key={id} {...rowProps} /> : <DesktopPostBody key={id} {...rowProps} />, ...(expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
|
||||
}
|
||||
const name = node.kind === "folder" ? node.folder.name : node.kind === "purchase" ? node.purchase.description : node.cost.name;
|
||||
const content = node.kind === "folder" ? folderHeading(node) : node.kind === "purchase" ? purchaseContent(node) : billContent(node);
|
||||
const style = { paddingInlineStart: `${indent}px` };
|
||||
|
||||
@@ -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";
|
||||
@@ -211,14 +212,20 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
interface PostRowProps { row: MonthPostRow; monthId: MonthId; onChangeRule?: (postId: string) => void; indent?: number; }
|
||||
interface PostRowProps { row: MonthPostRow; monthId: MonthId; onChangeRule?: (postId: string) => void; indent?: number; childrenExpanded?: boolean; onToggleChildren?: () => void; }
|
||||
|
||||
export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0 }: PostRowProps) {
|
||||
function PostChildrenToggle({ postName, expanded, onToggle }: { postName: string; expanded: boolean; onToggle: () => void }) {
|
||||
const Icon = expanded ? ChevronDown : ChevronRight;
|
||||
return <button type="button" aria-label={`Bills under ${postName}`} aria-expanded={expanded} onClick={onToggle} className="mr-1 inline-flex size-6 align-middle items-center justify-center rounded-sm text-budget-ink-muted hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" /></button>;
|
||||
}
|
||||
|
||||
export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
|
||||
const cells = postRowCells(row, useMoneyFormat());
|
||||
return (
|
||||
<tbody data-budget-item={row.post.id} data-budget-name={row.post.name} className="group">
|
||||
<tr className="transition-colors group-hover:bg-accent/60">
|
||||
<td className="pb-0 pt-3" style={{ paddingInlineStart: indent }}>
|
||||
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
|
||||
<PostName row={row} monthId={monthId} />
|
||||
{onChangeRule && (
|
||||
<ChangeRuleButton
|
||||
@@ -257,12 +264,13 @@ export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0 }: Post
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePostRow({ row, monthId, onChangeRule, indent = 0 }: PostRowProps) {
|
||||
export function MobilePostRow({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
|
||||
const cells = postRowCells(row, useMoneyFormat());
|
||||
return (
|
||||
<li data-budget-item={row.post.id} data-budget-name={row.post.name} className="space-y-1.5" style={{ paddingInlineStart: indent }}>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0">
|
||||
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
|
||||
<PostName row={row} monthId={monthId} />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { BUILD_VERSION } from "../../buildVersion.ts";
|
||||
import { BUILD_VERSION, COMMIT_HREF, SOURCE_REPOSITORY } from "../../buildVersion.ts";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
|
||||
const REPO = "github.com/StereotypicalCat/budget2.0";
|
||||
const REPO = SOURCE_REPOSITORY.replace(/^https?:\/\//, "");
|
||||
|
||||
/**
|
||||
* Production notes, in the old sense of the word: where this came from, what it
|
||||
@@ -29,7 +29,7 @@ export function ColophonSection() {
|
||||
whole position is that the data never leaves the browser should
|
||||
not hand this page's URL to another origin on the way out. */}
|
||||
<a
|
||||
href={`https://${REPO}`}
|
||||
href={SOURCE_REPOSITORY}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-budget-accent underline underline-offset-2 hover:no-underline"
|
||||
@@ -51,7 +51,7 @@ export function ColophonSection() {
|
||||
<span className="font-money">{dataset.settings.schemaVersion}</span>
|
||||
</Entry>
|
||||
<Entry label="Build">
|
||||
<span className="font-money">{BUILD_VERSION}</span>
|
||||
{COMMIT_HREF ? <a href={COMMIT_HREF} target="_blank" rel="noopener noreferrer" className="font-money text-budget-accent underline underline-offset-2 hover:no-underline">{BUILD_VERSION}</a> : <span className="font-money">{BUILD_VERSION}</span>}
|
||||
</Entry>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user