fix: nest legacy bills and stamp builds with commit
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s

This commit is contained in:
Lucas Winther
2026-09-30 14:35:29 +02:00
parent 85a21c58eb
commit f02c452d6c
17 changed files with 214 additions and 27 deletions
+3
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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
+2 -2
View File
@@ -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
```
+26 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+63 -5
View File
@@ -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();
});
+24
View File
@@ -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 {
+16 -1
View File
@@ -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
View File
@@ -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());
+25 -1
View File
@@ -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);
}
});
+4 -1
View File
@@ -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` };
+11 -3
View File
@@ -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
+4 -4
View File
@@ -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>