feat(ui): add app shell, router, and base path handling

Base path comes from BUN_PUBLIC_BASE_PATH so the same bundle serves both
a root Docker deploy and a GitHub Pages subpath.

Deviation from the task-16 brief: does not install @happy-dom/global-registrator,
does not create test/setup.ts, and does not add a [test] preload to bunfig.toml.
A global DOM preload would make src/domain/smoke.test.ts's "importable without a
DOM" assertion vacuously true, silently retiring the check that guards the
domain-layer-stays-pure invariant. Nothing in this task needs a DOM: basePath.test.ts
is pure string logic, and the shell render test was intentionally omitted by the
brief itself. A later task will register happy-dom per-file where a DOM is
genuinely required.
This commit is contained in:
Lucas Winther committed 2026-09-01 20:17:57 +02:00
1 parent 69bf4393fb
commit 7edcc7f31e
14 files changed
+168 -14

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import { BrowserRouter, Navigate, Route, Routes, NavLink } from "react-router";
import { ROUTER_BASENAME } from "./basePath.ts";
import { currentMonth } from "../store/index.ts";
import { MonthRoute } from "./routes/MonthRoute.tsx";
import { YearRoute } from "./routes/YearRoute.tsx";
import { PostMonthRoute } from "./routes/PostMonthRoute.tsx";
import { PostYearRoute } from "./routes/PostYearRoute.tsx";
import { SummaryRoute } from "./routes/SummaryRoute.tsx";
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
export function AppRoutes() {
const year = currentMonth.slice(0, 4);
return (
<div className="min-h-screen bg-background text-foreground">
<nav className="flex gap-4 border-b px-6 py-3 text-sm">
<NavLink to={`/month/${currentMonth}`} className="hover:underline">
Month
</NavLink>
<NavLink to={`/year/${year}`} className="hover:underline">
Year
</NavLink>
<NavLink to="/summary" className="hover:underline">
Summary
</NavLink>
<NavLink to="/settings" className="ml-auto hover:underline">
Settings
</NavLink>
</nav>
<main className="p-6">
<Routes>
<Route path="/month/:monthId" element={<MonthRoute />} />
<Route path="/year/:year" element={<YearRoute />} />
<Route path="/post/:postId/month/:monthId" element={<PostMonthRoute />} />
<Route path="/post/:postId/year/:year" element={<PostYearRoute />} />
<Route path="/summary" element={<SummaryRoute />} />
<Route path="/settings" element={<SettingsRoute />} />
<Route path="*" element={<Navigate to={`/month/${currentMonth}`} replace />} />
</Routes>
</main>
</div>
);
}
export function App() {
return (
<BrowserRouter basename={ROUTER_BASENAME}>
<AppRoutes />
</BrowserRouter>
);
}
export default App;
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from "bun:test";
import { withBase, normalizeBase } from "./basePath.ts";
test("normalizeBase defaults to root", () => {
expect(normalizeBase(undefined)).toBe("/");
expect(normalizeBase("")).toBe("/");
});
test("normalizeBase adds the leading and trailing slash", () => {
expect(normalizeBase("budget2.0")).toBe("/budget2.0/");
expect(normalizeBase("/budget2.0")).toBe("/budget2.0/");
expect(normalizeBase("/budget2.0/")).toBe("/budget2.0/");
});
test("withBase joins without doubling slashes", () => {
expect(withBase("/manifest.webmanifest", "/budget2.0/")).toBe(
"/budget2.0/manifest.webmanifest",
);
expect(withBase("manifest.webmanifest", "/budget2.0/")).toBe(
"/budget2.0/manifest.webmanifest",
);
expect(withBase("/sw.js", "/")).toBe("/sw.js");
});
+19
View File
@@ -0,0 +1,19 @@
/** Always "/" or "/segment/". Never empty, never without a trailing slash. */
export function normalizeBase(raw: string | undefined): string {
if (!raw || raw === "/") return "/";
const trimmed = raw.replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed === "" ? "/" : `/${trimmed}/`;
}
/**
* Bun substitutes process.env.BUN_PUBLIC_* into client code at build time, so
* this becomes a literal string in the bundle.
*/
export const BASE_PATH = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
/** react-router wants the basename without a trailing slash ("" for root). */
export const ROUTER_BASENAME = BASE_PATH === "/" ? "" : BASE_PATH.slice(0, -1);
export function withBase(path: string, base: string = BASE_PATH): string {
return `${base}${path.replace(/^\/+/, "")}`;
}
+14
View File
@@ -0,0 +1,14 @@
import { useSyncExternalStore } from "react";
import { store } from "../../store/index.ts";
import type { Dataset } from "../../domain/types.ts";
/**
* Subscribes to the snapshot store. Every mutation replaces the snapshot
* object, so reference equality is a correct change signal.
*/
export function useDataset(): Dataset {
return useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.get(),
);
}
+6
View File
@@ -0,0 +1,6 @@
import { useParams } from "react-router";
export function MonthRoute() {
const { monthId } = useParams();
return <h1 className="text-2xl font-semibold">{monthId}</h1>;
}
+10
View File
@@ -0,0 +1,10 @@
import { useParams } from "react-router";
export function PostMonthRoute() {
const { postId, monthId } = useParams();
return (
<h1 className="text-2xl font-semibold">
Post {postId} — {monthId}
</h1>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { useParams } from "react-router";
export function PostYearRoute() {
const { postId, year } = useParams();
return (
<h1 className="text-2xl font-semibold">
Post {postId} — Year {year}
</h1>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function SettingsRoute() {
return <h1 className="text-2xl font-semibold">Settings</h1>;
}
+3
View File
@@ -0,0 +1,3 @@
export function SummaryRoute() {
return <h1 className="text-2xl font-semibold">Summary</h1>;
}
+6
View File
@@ -0,0 +1,6 @@
import { useParams } from "react-router";
export function YearRoute() {
const { year } = useParams();
return <h1 className="text-2xl font-semibold">Year {year}</h1>;
}