From ba2506610502c51c3a5d464badb1a8629a1e0d83 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:52:42 -0400 Subject: [PATCH] Step 8.3: add server-synced clock Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++++ apps/web/src/clock/GameClock.tsx | 14 +++++++ apps/web/src/clock/clock.ts | 42 +++++++++++++++++++++ apps/web/src/layout/Layout.tsx | 5 ++- apps/web/test/clock.test.tsx | 63 ++++++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/clock/GameClock.tsx create mode 100644 apps/web/src/clock/clock.ts create mode 100644 apps/web/test/clock.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 1e8a6e6..cd7589f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -375,3 +375,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20). - **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format). - **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters. + +## Step 8.3 + +- **The clock offset lives in a Zustand store** (`useClock`), as the tech stack prescribes for real-time client state. `serverNow()` is `Date.now() + offset`; the device clock only affects visuals and is never trusted for outcomes. +- **Sync uses the midpoint of the round trip:** offset = server time − (sent + received) / 2. +- **A one-second ticker** (`startClockTicker`) refreshes `now` in the store so components re-render; components read `useClock(s => s.now)`. +- **The header shows in-game time** as `YYYY-MM-DD HH:MM` (UTC), from sim `realToGame`. diff --git a/apps/web/src/clock/GameClock.tsx b/apps/web/src/clock/GameClock.tsx new file mode 100644 index 0000000..4624e4b --- /dev/null +++ b/apps/web/src/clock/GameClock.tsx @@ -0,0 +1,14 @@ +import { content } from "@deadhead/content"; +import { realToGame } from "@deadhead/sim"; +import { useClock } from "./clock"; + +/** "2100-03-14 08:00": the in-game date and hour, in UTC. */ +export function formatGameTime(gameTime: number): string { + return new Date(gameTime).toISOString().slice(0, 16).replace("T", " "); +} + +/** The ticking in-game clock shown in the header. */ +export function GameClock() { + const now = useClock((state) => state.now); + return ; +} diff --git a/apps/web/src/clock/clock.ts b/apps/web/src/clock/clock.ts new file mode 100644 index 0000000..c9136a4 --- /dev/null +++ b/apps/web/src/clock/clock.ts @@ -0,0 +1,42 @@ +import type { RealTime } from "@deadhead/sim"; +import { create } from "zustand"; +import type { ApiClient } from "../api/client"; + +interface ClockState { + /** Server time minus device time. A wrong device clock only shifts visuals. */ + offsetMs: number; + synced: boolean; + /** Server-adjusted time, refreshed every second by the ticker. */ + now: RealTime; +} + +export const useClock = create(() => ({ + offsetMs: 0, + synced: false, + now: Date.now(), +})); + +/** The current time on the server's clock: device time plus the synced offset. */ +export function serverNow(): RealTime { + return Date.now() + useClock.getState().offsetMs; +} + +/** + * Fetches server time and stores the offset. The server's reading is assumed + * to be taken halfway through the round trip. + */ +export async function syncClock(api: ApiClient): Promise { + 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