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:
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export function App() {
|
||||
return <h1 className="p-8 text-2xl font-semibold">Budget 2.0</h1>;
|
||||
}
|
||||
export default App;
|
||||
+16
-10
@@ -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 = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
const container = document.getElementById("root") ?? document.body;
|
||||
|
||||
store.load().then(
|
||||
() => createRoot(container).render(<App />),
|
||||
(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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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(/^\/+/, "")}`;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useParams } from "react-router";
|
||||
|
||||
export function MonthRoute() {
|
||||
const { monthId } = useParams();
|
||||
return <h1 className="text-2xl font-semibold">{monthId}</h1>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SettingsRoute() {
|
||||
return <h1 className="text-2xl font-semibold">Settings</h1>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SummaryRoute() {
|
||||
return <h1 className="text-2xl font-semibold">Summary</h1>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user