From 7edcc7f31eae095d9774f9f0d7d768b274f7c784 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 20:17:57 +0200 Subject: [PATCH] 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. --- bun.lock | 5 +++ package.json | 1 + src/App.tsx | 4 --- src/frontend.tsx | 26 ++++++++++------ src/ui/App.tsx | 52 ++++++++++++++++++++++++++++++++ src/ui/basePath.test.ts | 23 ++++++++++++++ src/ui/basePath.ts | 19 ++++++++++++ src/ui/hooks/useDataset.ts | 14 +++++++++ src/ui/routes/MonthRoute.tsx | 6 ++++ src/ui/routes/PostMonthRoute.tsx | 10 ++++++ src/ui/routes/PostYearRoute.tsx | 10 ++++++ src/ui/routes/SettingsRoute.tsx | 3 ++ src/ui/routes/SummaryRoute.tsx | 3 ++ src/ui/routes/YearRoute.tsx | 6 ++++ 14 files changed, 168 insertions(+), 14 deletions(-) delete mode 100644 src/App.tsx create mode 100644 src/ui/App.tsx create mode 100644 src/ui/basePath.test.ts create mode 100644 src/ui/basePath.ts create mode 100644 src/ui/hooks/useDataset.ts create mode 100644 src/ui/routes/MonthRoute.tsx create mode 100644 src/ui/routes/PostMonthRoute.tsx create mode 100644 src/ui/routes/PostYearRoute.tsx create mode 100644 src/ui/routes/SettingsRoute.tsx create mode 100644 src/ui/routes/SummaryRoute.tsx create mode 100644 src/ui/routes/YearRoute.tsx diff --git a/bun.lock b/bun.lock index 08c6beb..77160e9 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "lucide-react": "^1", "react": "^19", "react-dom": "^19", + "react-router": "^8.3.1", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.4.0", }, @@ -133,6 +134,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -151,6 +154,8 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-router": ["react-router@8.3.1", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], diff --git a/package.json b/package.json index 1962a3d..f324c02 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lucide-react": "^1", "react": "^19", "react-dom": "^19", + "react-router": "^8.3.1", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.4.0" }, diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index e6d31f7..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export function App() { - return

Budget 2.0

; -} -export default App; diff --git a/src/frontend.tsx b/src/frontend.tsx index cbfcf48..60d7c39 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -3,18 +3,24 @@ * element and renders the App component to the DOM. * * It is included in `src/index.html`. + * + * The store must load before React renders: `useDataset` calls `store.get()`, + * which throws if the snapshot has not loaded yet, so the UI can never render + * a blank budget as though the user had no data. */ -import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./App"; +import "./index.css"; +import { App } from "./ui/App.tsx"; +import { store } from "./store/index.ts"; -const elem = document.getElementById("root")!; -const app = ( - - - +const container = document.getElementById("root") ?? document.body; + +store.load().then( + () => createRoot(container).render(), + (error: unknown) => { + container.textContent = `Could not open your budget data: ${ + error instanceof Error ? error.message : String(error) + }`; + }, ); - -// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data -(import.meta.hot.data.root ??= createRoot(elem)).render(app); diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 0000000..43afa38 --- /dev/null +++ b/src/ui/App.tsx @@ -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 ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + +export function App() { + return ( + + + + ); +} + +export default App; diff --git a/src/ui/basePath.test.ts b/src/ui/basePath.test.ts new file mode 100644 index 0000000..0f102ba --- /dev/null +++ b/src/ui/basePath.test.ts @@ -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"); +}); diff --git a/src/ui/basePath.ts b/src/ui/basePath.ts new file mode 100644 index 0000000..3da17f7 --- /dev/null +++ b/src/ui/basePath.ts @@ -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(/^\/+/, "")}`; +} diff --git a/src/ui/hooks/useDataset.ts b/src/ui/hooks/useDataset.ts new file mode 100644 index 0000000..8b5534f --- /dev/null +++ b/src/ui/hooks/useDataset.ts @@ -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(), + ); +} diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx new file mode 100644 index 0000000..a39363c --- /dev/null +++ b/src/ui/routes/MonthRoute.tsx @@ -0,0 +1,6 @@ +import { useParams } from "react-router"; + +export function MonthRoute() { + const { monthId } = useParams(); + return

{monthId}

; +} diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx new file mode 100644 index 0000000..53d5456 --- /dev/null +++ b/src/ui/routes/PostMonthRoute.tsx @@ -0,0 +1,10 @@ +import { useParams } from "react-router"; + +export function PostMonthRoute() { + const { postId, monthId } = useParams(); + return ( +

+ Post {postId} — {monthId} +

+ ); +} diff --git a/src/ui/routes/PostYearRoute.tsx b/src/ui/routes/PostYearRoute.tsx new file mode 100644 index 0000000..6243ef3 --- /dev/null +++ b/src/ui/routes/PostYearRoute.tsx @@ -0,0 +1,10 @@ +import { useParams } from "react-router"; + +export function PostYearRoute() { + const { postId, year } = useParams(); + return ( +

+ Post {postId} — Year {year} +

+ ); +} diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx new file mode 100644 index 0000000..e274140 --- /dev/null +++ b/src/ui/routes/SettingsRoute.tsx @@ -0,0 +1,3 @@ +export function SettingsRoute() { + return

Settings

; +} diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx new file mode 100644 index 0000000..4204051 --- /dev/null +++ b/src/ui/routes/SummaryRoute.tsx @@ -0,0 +1,3 @@ +export function SummaryRoute() { + return

Summary

; +} diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx new file mode 100644 index 0000000..f231462 --- /dev/null +++ b/src/ui/routes/YearRoute.tsx @@ -0,0 +1,6 @@ +import { useParams } from "react-router"; + +export function YearRoute() { + const { year } = useParams(); + return

Year {year}

; +}