Step 8.1: add Vite + React client shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e58e99c156
commit
acf550c901
|
|
@ -360,3 +360,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
|
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
|
||||||
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
|
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
|
||||||
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
|
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
|
||||||
|
|
||||||
|
## Step 8.1
|
||||||
|
|
||||||
|
- **Client stack:** Vite 8 + React 19, Tailwind CSS 4 (via `@tailwindcss/vite`, no config file), React Router 8 in declarative mode (`<Routes>`; `BrowserRouter` in `main.tsx`, `MemoryRouter` in tests). Component tests use Vitest with jsdom and Testing Library (`apps/web/vitest.config.ts`).
|
||||||
|
- **The dev server proxies `/api` to `http://127.0.0.1:3000`**, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
|
||||||
|
- **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
|
||||||
|
- **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
|
||||||
|
- **ESLint gives `apps/web` browser globals.**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>Deadhead: Space Freight Tycoon</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -3,9 +3,33 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
|
||||||
"types": "./src/index.ts",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@deadhead/content": "workspace:*",
|
||||||
|
"@deadhead/schema": "workspace:*",
|
||||||
|
"@deadhead/sim": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"react-router": "^8.4.0",
|
||||||
|
"zod": "^4.6.5",
|
||||||
|
"zustand": "^5.0.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"jsdom": "^30.1.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"vite": "^8.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Route, Routes } from "react-router";
|
||||||
|
import { Layout } from "./layout/Layout";
|
||||||
|
import { SCREENS } from "./routes";
|
||||||
|
import { Placeholder } from "./screens/Placeholder";
|
||||||
|
|
||||||
|
/** Routes for every screen. The router itself is provided by the caller. */
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
{SCREENS.map((screen) => (
|
||||||
|
<Route
|
||||||
|
key={screen.path}
|
||||||
|
index={screen.path === "/"}
|
||||||
|
path={screen.path === "/" ? undefined : screen.path}
|
||||||
|
element={<Placeholder title={screen.title} />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export {};
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { NavLink, Outlet } from "react-router";
|
||||||
|
import { SCREENS } from "../routes";
|
||||||
|
|
||||||
|
export interface LayoutProps {
|
||||||
|
/** Shown at the top right; live values arrive with the player state and clock steps. */
|
||||||
|
credits?: ReactNode;
|
||||||
|
clock?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
|
||||||
|
export function Layout({ credits = "—", clock = "—" }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
|
||||||
|
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
|
||||||
|
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
|
||||||
|
<span aria-label="Credits">{credits}</span>
|
||||||
|
<span aria-label="Game clock" className="text-slate-400">
|
||||||
|
{clock}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-x-hidden pb-20">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Main"
|
||||||
|
className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
||||||
|
>
|
||||||
|
{SCREENS.map((screen) => (
|
||||||
|
<NavLink
|
||||||
|
key={screen.path}
|
||||||
|
to={screen.path}
|
||||||
|
end={screen.path === "/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{screen.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** The app's top-level screens, in bottom-navigation order. */
|
||||||
|
export const SCREENS = [
|
||||||
|
{ path: "/", title: "HQ", label: "HQ" },
|
||||||
|
{ path: "/contracts", title: "Contracts", label: "Contracts" },
|
||||||
|
{ path: "/fleet", title: "Fleet", label: "Fleet" },
|
||||||
|
{ path: "/pilots", title: "Pilots", label: "Pilots" },
|
||||||
|
{ path: "/market", title: "Market", label: "Market" },
|
||||||
|
{ path: "/locations", title: "Locations", label: "Places" },
|
||||||
|
] as const;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/** A screen that has not been built yet (Phase 9 fills these in). */
|
||||||
|
export function Placeholder({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<section className="p-4">
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { App } from "../src/App";
|
||||||
|
import { SCREENS } from "../src/routes";
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("app layout", () => {
|
||||||
|
it("renders the header with credits and game clock, and the bottom navigation", () => {
|
||||||
|
renderAt("/");
|
||||||
|
expect(screen.getByRole("banner")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Credits")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Game clock")).toBeInTheDocument();
|
||||||
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||||
|
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SCREENS)("shows the $title placeholder at $path", ({ path, title }) => {
|
||||||
|
renderAt(path);
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates between screens with the bottom navigation", async () => {
|
||||||
|
renderAt("/");
|
||||||
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||||
|
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
|
||||||
|
expect(within(nav).getByRole("link", { name: "Fleet" })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src", "test"]
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
// Same-domain API in development too (decision 0.5.20). The server listens on
|
||||||
|
// IPv4 only, so target 127.0.0.1 rather than localhost.
|
||||||
|
proxy: { "/api": "http://127.0.0.1:3000" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineProject } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineProject({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./test/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,12 @@ export default tseslint.config(
|
||||||
globals: { ...globals.node },
|
globals: { ...globals.node },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["apps/web/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],
|
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,11 @@
|
||||||
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
|
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
|
||||||
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
|
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
|
||||||
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
|
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
|
||||||
|
|
||||||
|
## Phase 8 — Client Foundation
|
||||||
|
|
||||||
|
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
|
||||||
|
- [ ] Step 8.2 — Typed API client
|
||||||
|
- [ ] Step 8.3 — Server-synced clock
|
||||||
|
- [ ] Step 8.4 — Guest onboarding
|
||||||
|
- [ ] Step 8.5 — Player state store
|
||||||
|
|
|
||||||
1117
pnpm-lock.yaml
1117
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue