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}
+ + ); +}