feat(settings): add a colophon, with a link to the source

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 22:09:14 +02:00
co-authored by Claude Opus 5
parent a73cff13a3
commit 61e7093c36
6 changed files with 162 additions and 1 deletions
+4
View File
@@ -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)),
+1 -1
View File
@@ -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.
+40
View File
@@ -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;
}
});
+45
View File
@@ -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());
+2
View File
@@ -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() {
<CurrencySection />
<PostsSection />
<DataSection />
<ColophonSection />
</div>
);
}
@@ -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 (
<Section title="Colophon" hint="Envelope budgeting that lives entirely in your browser.">
<dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[7.5rem_1fr]">
<Entry label="Source">
{/* 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. */}
<a
href={`https://${REPO}`}
target="_blank"
rel="noopener noreferrer"
className="text-budget-accent underline underline-offset-2 hover:no-underline"
>
{REPO}
</a>
<span aria-hidden="true" className="ml-1 text-budget-ink-muted">
&#8599;
</span>
</Entry>
<Entry label="Built with">Bun, React 19, Tailwind 4, shadcn/ui</Entry>
<Entry label="Typefaces">
Inter and JetBrains Mono, self-hosted under the SIL Open Font License 1.1
</Entry>
<Entry label="Storage">
IndexedDB in this browser
<span className="text-budget-ink-muted"> · </span>
schema version{" "}
<span className="font-money">{dataset.settings.schemaVersion}</span>
</Entry>
<Entry label="Build">
<span className="font-money">{BUILD_VERSION}</span>
</Entry>
</dl>
</Section>
);
}
function Entry({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted sm:pt-0.5">
{label}
</dt>
<dd className="min-w-0">{children}</dd>
</>
);
}