{
+ const sentAt = Date.now();
+ const { realTime } = await api.call("getServerTime", {});
+ const receivedAt = Date.now();
+ const offsetMs = realTime - (sentAt + receivedAt) / 2;
+ useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
+}
+
+/** Updates `now` every second. Returns a function that stops the ticker. */
+export function startClockTicker(intervalMs = 1000): () => void {
+ const tick = () => useClock.setState({ now: serverNow() });
+ tick();
+ const id = setInterval(tick, intervalMs);
+ return () => clearInterval(id);
+}
diff --git a/apps/web/src/layout/Layout.tsx b/apps/web/src/layout/Layout.tsx
index 7e50cf0..7a82582 100644
--- a/apps/web/src/layout/Layout.tsx
+++ b/apps/web/src/layout/Layout.tsx
@@ -1,15 +1,16 @@
import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
+import { GameClock } from "../clock/GameClock";
import { SCREENS } from "../routes";
export interface LayoutProps {
- /** Shown at the top right; live values arrive with the player state and clock steps. */
+ /** Shown at the top right. Credits arrive with the player state (Step 8.5). */
credits?: ReactNode;
clock?: ReactNode;
}
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
-export function Layout({ credits = "—", clock = "—" }: LayoutProps) {
+export function Layout({ credits = "—", clock = }: LayoutProps) {
return (
diff --git a/apps/web/test/clock.test.tsx b/apps/web/test/clock.test.tsx
new file mode 100644
index 0000000..7aa7f4d
--- /dev/null
+++ b/apps/web/test/clock.test.tsx
@@ -0,0 +1,63 @@
+import { content } from "@deadhead/content";
+import { realToGame } from "@deadhead/sim";
+import { act, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ApiClient } from "../src/api/client";
+import { serverNow, startClockTicker, syncClock, useClock } from "../src/clock/clock";
+import { formatGameTime, GameClock } from "../src/clock/GameClock";
+
+const DEVICE_NOW = Date.parse("2100-01-01T00:00:00Z");
+const TEN_MINUTES = 10 * 60_000;
+
+/** An API whose server clock runs `aheadMs` ahead of the device. */
+function serverAhead(aheadMs: number): ApiClient {
+ return {
+ call: vi.fn(async () => ({ realTime: Date.now() + aheadMs, gameTime: 0 })),
+ } as unknown as ApiClient;
+}
+
+describe("server-synced clock", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(DEVICE_NOW);
+ useClock.setState({ offsetMs: 0, synced: false, now: DEVICE_NOW });
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("returns a time 10 minutes ahead when the server is 10 minutes ahead", async () => {
+ await syncClock(serverAhead(TEN_MINUTES));
+ expect(useClock.getState().synced).toBe(true);
+ expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES);
+
+ vi.advanceTimersByTime(5000);
+ expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES + 5000);
+ });
+
+ it("works when the server is behind the device", async () => {
+ await syncClock(serverAhead(-30_000));
+ expect(serverNow()).toBe(DEVICE_NOW - 30_000);
+ });
+
+ it("ticks the store every second", async () => {
+ await syncClock(serverAhead(TEN_MINUTES));
+ const stop = startClockTicker();
+ vi.advanceTimersByTime(3000);
+ expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
+ stop();
+ vi.advanceTimersByTime(3000);
+ expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
+ });
+
+ it("shows the in-game time in the game clock and keeps it ticking", async () => {
+ await syncClock(serverAhead(0));
+ const stop = startClockTicker();
+ render();
+ const at = (real: number) => formatGameTime(realToGame(real, content.config));
+ expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
+
+ // One real minute is one game hour.
+ act(() => vi.advanceTimersByTime(60_000));
+ expect(screen.getByText(at(DEVICE_NOW + 60_000))).toBeInTheDocument();
+ stop();
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 612745c..ef7fa84 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -79,6 +79,6 @@
- [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.
- [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message.
-- [ ] Step 8.3 — Server-synced clock
+- [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time.
- [ ] Step 8.4 — Guest onboarding
- [ ] Step 8.5 — Player state store