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