From 61e7093c36f9a0111a5f1c8776aba4894c88ebc4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 22:09:14 +0200 Subject: [PATCH] feat(settings): add a colophon, with a link to the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production notes at the foot of Settings: where this came from, what it was built with, what it is set in, and what it stores where. The schema version is real, read from the dataset rather than written into the copy, because it is the number that matters when someone opens a backup and wonders what will read it. The build stamp is new plumbing, and small: build.ts already computed a `version` for the service worker, so the app bundle now gets the same value. Read through a try/catch accessor, never a `typeof process` guard (AGENTS.md §2) — and genuinely absent in development, which is the normal case and reads as "development" rather than as an error. A caller's own BUILD_VERSION is printed as given; only build.ts's default timestamp is translated, because "1788379707862" tells a reader nothing and "2026-09-02" tells them what they wanted. ISO, not a locale format: this string ends up in bug reports and should not vary by who reads it. rel="noreferrer" on the link is deliberate rather than boilerplate. An app whose whole position is that the data never leaves the browser should not hand this page's URL to another origin on the way out. No licence is claimed for the app: there is no LICENSE file in the repository, and asserting one in the UI would be worse than saying nothing. The font licences are committed beside the files, so those are named. Verified: both build paths inline the value and leave no bare process.env reference in the bundle (BUILD_VERSION=v1.4.0-test appears literally, the default appears as a timestamp), and the section was read in both themes. Co-Authored-By: Claude Opus 5 (1M context) --- build.ts | 4 ++ docs/TODO.md | 2 +- src/ui/buildVersion.test.ts | 40 +++++++++++++ src/ui/buildVersion.ts | 45 ++++++++++++++ src/ui/routes/SettingsRoute.tsx | 2 + src/ui/routes/settings/ColophonSection.tsx | 70 ++++++++++++++++++++++ 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/ui/buildVersion.test.ts create mode 100644 src/ui/buildVersion.ts create mode 100644 src/ui/routes/settings/ColophonSection.tsx diff --git a/build.ts b/build.ts index e9e35ac..c4a9ecd 100644 --- a/build.ts +++ b/build.ts @@ -104,6 +104,10 @@ const result = await Bun.build({ define: { "process.env.NODE_ENV": JSON.stringify("production"), "process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath), + // The same `version` the service worker gets, so the colophon can name the + // 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), // 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/TODO.md b/docs/TODO.md index 52c7ad9..f1cb7fd 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`, not yet pushed. 446 tests passing, +**Current state:** merged to `main`. 452 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. diff --git a/src/ui/buildVersion.test.ts b/src/ui/buildVersion.test.ts new file mode 100644 index 0000000..7598f99 --- /dev/null +++ b/src/ui/buildVersion.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from "bun:test"; +import { describeBuild, readBuildVersionEnv } from "./buildVersion.ts"; + +test("an absent stamp reads as development, not as a blank or a lie", () => { + expect(describeBuild(undefined)).toBe("development"); + expect(describeBuild("")).toBe("development"); +}); + +test("build.ts's default timestamp becomes a date", () => { + // String(Date.now()) for 2026-09-02T00:00:00Z. + expect(describeBuild("1788307200000")).toBe("2026-09-02"); +}); + +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. + expect(describeBuild("bd5a01e")).toBe("bd5a01e"); + expect(describeBuild("v1.4.0")).toBe("v1.4.0"); + expect(describeBuild("2026.09.02-nightly")).toBe("2026.09.02-nightly"); +}); + +test("digits that are not a real date fall back to the raw string", () => { + // 21 digits is past the range Date accepts, so this must not become + // "Invalid Date" or throw. + expect(describeBuild("999999999999999999999")).toBe("999999999999999999999"); +}); + +// Regression, same mechanism as basePath.test.ts: Bun only inlines +// `process.env.FOO` when FOO is SET. Unset, the reference survives into the +// browser bundle and throws `ReferenceError: process is not defined` before the +// app can boot, so reading it must tolerate `process` not existing at all. +test("reading the build version does not throw when process is undefined", () => { + const saved = (globalThis as { process?: unknown }).process; + try { + delete (globalThis as { process?: unknown }).process; + expect(readBuildVersionEnv()).toBeUndefined(); + } finally { + (globalThis as { process?: unknown }).process = saved; + } +}); diff --git a/src/ui/buildVersion.ts b/src/ui/buildVersion.ts new file mode 100644 index 0000000..031bedb --- /dev/null +++ b/src/ui/buildVersion.ts @@ -0,0 +1,45 @@ +/** + * The build stamp shown in the colophon. + * + * `build.ts` already computes a version for the service worker; this exposes + * the same value to the app bundle. It is genuinely absent in development, + * which is the normal case rather than an error. + */ + +/** + * try/catch, NOT a `typeof process` guard — see AGENTS.md §2. Bun inlines the + * literal only when the variable is set; unset, the bare reference reaches the + * browser and throws before the app boots, and a `typeof` guard would survive + * inlining and discard the inlined value. + */ +export function readBuildVersionEnv(): string | undefined { + try { + return process.env.BUN_PUBLIC_BUILD_VERSION; + } catch { + return undefined; + } +} + +/** A millisecond epoch, which is what build.ts falls back to. */ +const EPOCH_MS = /^\d{10,}$/; + +/** + * `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" + * 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 + * the linked repository, and it should not vary by who is reading it. + */ +export function describeBuild(raw: string | undefined): string { + if (!raw) return "development"; + if (EPOCH_MS.test(raw)) { + const at = new Date(Number(raw)); + if (!Number.isNaN(at.getTime())) return at.toISOString().slice(0, 10); + } + return raw; +} + +export const BUILD_VERSION = describeBuild(readBuildVersionEnv()); diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index e3c6e35..8d3387b 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -1,6 +1,7 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; +import { ColophonSection } from "./settings/ColophonSection.tsx"; /** * Each section is its own card. They are unrelated settings — money, posts, @@ -15,6 +16,7 @@ export function SettingsRoute() { + ); } diff --git a/src/ui/routes/settings/ColophonSection.tsx b/src/ui/routes/settings/ColophonSection.tsx new file mode 100644 index 0000000..1fa4bde --- /dev/null +++ b/src/ui/routes/settings/ColophonSection.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { useDataset } from "../../hooks/useDataset.ts"; +import { BUILD_VERSION } from "../../buildVersion.ts"; +import { Section } from "../../components/Section.tsx"; + +const REPO = "github.com/StereotypicalCat/budget2.0"; + +/** + * Production notes, in the old sense of the word: where this came from, what it + * was set in, and what it is written on. + * + * The schema version is here rather than only in the export because it is the + * number that matters when someone opens a backup and wonders what will read + * it. The build stamp is here because the source link invites bug reports, and + * "which build" is the first thing anyone will ask. + * + * No licence is claimed for the app itself: there is no LICENSE file in the + * repository, and asserting one in the UI would be worse than saying nothing. + * The font licences ARE committed, beside the files, so those are named. + */ +export function ColophonSection() { + const dataset = useDataset(); + + return ( +
+
+ + {/* rel="noreferrer" is deliberate, not boilerplate: an app whose + whole position is that the data never leaves the browser should + not hand this page's URL to another origin on the way out. */} + + {REPO} + + + + Bun, React 19, Tailwind 4, shadcn/ui + + Inter and JetBrains Mono, self-hosted under the SIL Open Font License 1.1 + + + IndexedDB in this browser + · + schema version{" "} + {dataset.settings.schemaVersion} + + + {BUILD_VERSION} + +
+
+ ); +} + +function Entry({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
+ {label} +
+
{children}
+ + ); +}