From 754242dde680ba0b0608bbacf2e55831d45fc981 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:11:34 -0400 Subject: [PATCH] Step 10K.3: clock, countdown and reduced-motion settings applied everywhere Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ apps/web/src/App.tsx | 2 + apps/web/src/clock/GameClock.tsx | 12 +-- apps/web/src/clock/timeFormat.ts | 68 ++++++++++++++ apps/web/src/components/Countdown.tsx | 15 ++- apps/web/src/components/Goods.tsx | 20 ++-- apps/web/src/index.css | 19 +++- apps/web/src/map/layout.ts | 8 ++ apps/web/src/map/renderer.ts | 6 +- apps/web/src/screens/DispatchSheet.tsx | 40 ++++++-- apps/web/src/screens/HQ.tsx | 4 +- apps/web/src/screens/MapScreen.tsx | 4 +- apps/web/src/state/preferences.ts | 14 +++ apps/web/test/clock.test.tsx | 3 +- apps/web/test/contract-card.test.tsx | 6 +- apps/web/test/dispatch-priority.test.tsx | 6 +- apps/web/test/dispatch-sheet.test.tsx | 2 +- apps/web/test/display-options.test.tsx | 112 +++++++++++++++++++++++ apps/web/test/layout.test.tsx | 3 +- memory-bank/progress.md | 2 +- 20 files changed, 306 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/clock/timeFormat.ts create mode 100644 apps/web/test/display-options.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index c7ac03d..96e8097 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1051,3 +1051,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Settings is a route (`/settings`) inside the normal layout**, reached only from the header gear, so the seven-tab navigation is unchanged and the back button works. - **Reduced motion is a three-way choice (Device / On / Off)**: "Device" stores no override, so the default keeps following the OS (decision 0.5.76). - **Items appear with the step that makes them work** (rename and new company, backup, version, developer tools), so no placeholder controls ship. + +## Step 10K.3 + +- **The default "device" clock changes how the game clock looks** on 12-hour devices ("2145-07-11 7:18 PM"); before, it was always 24-hour. That is decision 0.5.76's default; players who prefer 24-hour pick it in Settings. +- **Countdowns default to real time everywhere, Priority windows included.** CR-007's chip showed game hours and switched to real time on tap; it now shows the chosen countdown format, with both in its tooltip, so one setting governs every span. +- **Game-time spans read "4.3 game h"** so they can't be mistaken for real hours next to a real countdown. +- **Reduced motion has two sources**: the OS media query (unless the player turned motion back on) and the player's override. The map keeps moving ships but stills its background. +- **Pure helpers read preferences directly** (`displayPreferences()`) where they format strings outside React (the dispatch sheet's problem lines); those re-render every second with the clock anyway. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 639de91..3bcf6be 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,6 +15,7 @@ import { Locations } from "./screens/Locations"; import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; import { Settings } from "./screens/Settings"; +import { useMotionPreference } from "./state/preferences"; import { StartScreen } from "./screens/StartScreen"; import { usePlayerState, useRefetchOnArrival } from "./state/player"; import { Button } from "./ui"; @@ -50,6 +51,7 @@ function useServerClock() { /** The whole client. Router, API and query providers come from the caller. */ export function App() { useServerClock(); + useMotionPreference(); const state = usePlayerState(); useRefetchOnArrival(state.data ?? null, state.refetch); const retry = () => void state.refetch(); diff --git a/apps/web/src/clock/GameClock.tsx b/apps/web/src/clock/GameClock.tsx index 4624e4b..aa7d0c5 100644 --- a/apps/web/src/clock/GameClock.tsx +++ b/apps/web/src/clock/GameClock.tsx @@ -1,14 +1,14 @@ import { content } from "@deadhead/content"; import { realToGame } from "@deadhead/sim"; +import { usePreferences } from "../state/preferences"; import { useClock } from "./clock"; +import { formatGameTime } from "./timeFormat"; -/** "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", " "); -} +export { formatGameTime }; -/** The ticking in-game clock shown in the header. */ +/** The ticking in-game clock shown in the header, in the player's clock format. */ export function GameClock() { const now = useClock((state) => state.now); - return ; + const clock = usePreferences((p) => p.clock); + return ; } diff --git a/apps/web/src/clock/timeFormat.ts b/apps/web/src/clock/timeFormat.ts new file mode 100644 index 0000000..f4c104b --- /dev/null +++ b/apps/web/src/clock/timeFormat.ts @@ -0,0 +1,68 @@ +import { content } from "@deadhead/content"; +import { MS_PER_HOUR } from "@deadhead/sim"; +import { formatDuration } from "../format"; +import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; + +/** + * The one place times and spans are formatted for display (CR-011): clock + * times follow the clock preference, spans follow the countdown preference. + */ + +function hour12For(clock: ClockFormat): boolean | undefined { + return clock === "locale" ? undefined : clock === "12h"; +} + +/** A real time of day on this device, e.g. "14:05" or "2:05 PM". */ +export function formatTimeOfDay(time: number, clock: ClockFormat): string { + return new Date(time).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: hour12For(clock), + }); +} + +/** The in-game date and time (UTC), e.g. "2100-03-14 08:00" or "2100-03-14 8:00 AM". */ +export function formatGameTime(gameTime: number, clock: ClockFormat = "24h"): string { + const date = new Date(gameTime).toISOString().slice(0, 10); + if (clock === "24h") return `${date} ${new Date(gameTime).toISOString().slice(11, 16)}`; + const time = new Date(gameTime).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + hour12: hour12For(clock), + timeZone: "UTC", + }); + return `${date} ${time}`; +} + +/** A span of game hours, e.g. "4.3 game h" or "62 game h". */ +export function formatGameSpan(gameHours: number): string { + const rounded = gameHours < 10 ? gameHours.toFixed(1) : String(Math.round(gameHours)); + return `${rounded} game h`; +} + +/** + * A span of real milliseconds as the player wants countdowns: real time + * ("4m 16s"), game time ("4.3 game h") or both ("4m 16s · 4.3 game h"). + * `real` formats the real part (e.g. mm:ss for the board refresh). + */ +export function formatSpan( + ms: number, + mode: CountdownFormat, + real: (ms: number) => string = formatDuration, +): string { + const game = formatGameSpan((Math.max(0, ms) * content.config.timeCompression) / MS_PER_HOUR); + if (mode === "game") return game; + if (mode === "both") return `${real(ms)} · ${game}`; + return real(ms); +} + +/** A span given in game hours (e.g. a Priority window), in the countdown mode. */ +export function formatGameHoursSpan(gameHours: number, mode: CountdownFormat): string { + return formatSpan((gameHours * MS_PER_HOUR) / content.config.timeCompression, mode); +} + +/** The current preferences, for formatting outside React components. */ +export function displayPreferences(): { clock: ClockFormat; countdown: CountdownFormat } { + const { clock, countdown } = usePreferences.getState(); + return { clock, countdown }; +} diff --git a/apps/web/src/components/Countdown.tsx b/apps/web/src/components/Countdown.tsx index 9f99d83..21e28ff 100644 --- a/apps/web/src/components/Countdown.tsx +++ b/apps/web/src/components/Countdown.tsx @@ -1,7 +1,13 @@ import { useClock } from "../clock/clock"; +import { formatSpan } from "../clock/timeFormat"; import { formatDuration } from "../format"; +import { usePreferences } from "../state/preferences"; -/** Live time remaining until `to` (server clock); shows `done` once it has passed. */ +/** + * Live time remaining until `to` (server clock), in the player's countdown + * format (real, game or both; CR-011); shows `done` once it has passed. + * `format` styles the real-time part (e.g. mm:ss). + */ export function Countdown({ to, done = "Arriving…", @@ -12,6 +18,11 @@ export function Countdown({ format?: (ms: number) => string; }) { const now = useClock((s) => s.now); + const mode = usePreferences((p) => p.countdown); const remaining = to - now; - return {remaining > 0 ? format(remaining) : done}; + return ( + + {remaining > 0 ? formatSpan(remaining, mode, format) : done} + + ); } diff --git a/apps/web/src/components/Goods.tsx b/apps/web/src/components/Goods.tsx index cfbe224..681cf36 100644 --- a/apps/web/src/components/Goods.tsx +++ b/apps/web/src/components/Goods.tsx @@ -1,8 +1,9 @@ import { content } from "@deadhead/content"; import type { Contract, PlayerState } from "@deadhead/schema"; import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim"; +import { formatGameHoursSpan } from "../clock/timeFormat"; +import { usePreferences } from "../state/preferences"; import { shipStatsOf } from "../game"; -import { useState } from "react"; import { cargoName, formatDuration } from "../format"; import { Pill, type Tone } from "../ui"; @@ -41,25 +42,20 @@ export function ClassChip({ contract }: { contract: Pick p.countdown); return ( - + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ffc9b44..b1d4e79 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -95,13 +95,24 @@ } } -/* Motion is decoration only: switch it off when asked. */ +/* + * Motion is decoration only: switch it off when asked. The app sets + * data-reduced-motion on from the player's setting (CR-011); without + * an override it follows the device. + */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + :root:not([data-reduced-motion="false"]) *, + :root:not([data-reduced-motion="false"]) *::before, + :root:not([data-reduced-motion="false"]) *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } +:root[data-reduced-motion="true"] *, +:root[data-reduced-motion="true"] *::before, +:root[data-reduced-motion="true"] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; +} diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index a153574..8a833c4 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -310,3 +310,11 @@ export function decorate( } return result; } + +/** + * The map style with motion settings applied (CR-011): reduced motion keeps the + * background still (no parallax, no zoom follow). Ships still move. + */ +export function mapMotionStyle(reducedMotion: boolean, style: MapStyle = mapStyle): MapStyle { + return reducedMotion ? { ...style, parallax: 0, backgroundZoomFollow: 0 } : style; +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index 613c177..09ca9a0 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -2,7 +2,8 @@ import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } fr import type { ArtTextures } from "./art/load"; import { sceneVisuals, type Visual } from "./art/visuals"; import { toScreen, type Camera, type Viewport } from "./camera"; -import { backgroundRect, decorate, layoutScene, polar } from "./layout"; +import { reducedMotionOn, usePreferences } from "../state/preferences"; +import { backgroundRect, decorate, layoutScene, mapMotionStyle, polar } from "./layout"; import type { MapSelection } from "./hitTest"; import { locationOverlays } from "./overlays"; import type { MapPoint } from "./projection"; @@ -136,7 +137,8 @@ export class MapRenderer { for (const sprite of this.sprites.values()) sprite.visible = false; if (this.background) { - const r = backgroundRect(view, camera, this.background.texture); + const style = mapMotionStyle(reducedMotionOn(usePreferences.getState())); + const r = backgroundRect(view, camera, this.background.texture, style); this.background.position.set(r.x, r.y); this.background.setSize(r.width, r.height); } diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index b6dd34a..0dbfb49 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -31,7 +31,14 @@ import { location, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Sheet } from "../ui"; -import { formatWindow, goodName, PriorityChip } from "../components/Goods"; +import { goodName, PriorityChip } from "../components/Goods"; +import { + displayPreferences, + formatGameHoursSpan, + formatSpan, + formatTimeOfDay, +} from "../clock/timeFormat"; +import { usePreferences } from "../state/preferences"; /** Short player-facing reasons a ship+pilot cannot take a contract. */ export const INELIGIBLE_REASONS: Record = { @@ -68,7 +75,9 @@ export function explainProblems( const problems: string[] = []; // Resting blocks flying (CR-004): first, with the time left. if (pilot.resting && pilot.restEndsAt !== null) { - problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`); + problems.push( + `Resting — ${formatSpan(pilot.restEndsAt - now, displayPreferences().countdown, formatClock)}`, + ); } if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]); if (contract.massTonnes > model.cargoCapacityTonnes) { @@ -92,7 +101,9 @@ export function explainProblems( if (window !== null) { const trip = contractTripGameHours(model, contract, now, content); if (trip > window) { - problems.push(`Too slow — ${formatWindow(trip)} vs ${formatWindow(window)} window`); + problems.push( + `Too slow — ${formatGameHoursSpan(trip, displayPreferences().countdown)} vs ${formatGameHoursSpan(window, displayPreferences().countdown)} window`, + ); } } if (contract.requiredSkill) { @@ -116,7 +127,7 @@ export function explainProblems( /** Real-world arrival time on the device, e.g. "14:05". */ export function formatLocalTime(time: number): string { - return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return formatTimeOfDay(time, displayPreferences().clock); } export interface PairOption { @@ -201,7 +212,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now); // In the shop (CR-009): first, with the time left. if (isInShop(ship, now)) - all.unshift(`In the shop — ${formatClock(ship.inShopUntil! - now)}`); + all.unshift( + `In the shop — ${formatSpan(ship.inShopUntil! - now, displayPreferences().countdown, formatClock)}`, + ); problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]]; } } @@ -233,6 +246,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) /** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) { const state = useLoadedPlayer(); + const prefsCountdown = usePreferences((p) => p.countdown); const now = useClock((s) => s.now); const dispatch = useGameAction("createDispatch"); const options = pairOptions(state, contract, now); @@ -339,7 +353,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo {option.duty && ( @@ -352,9 +369,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo )} {!blocked && option.priority && (

- Priority: {formatWindow(option.priority.tripHours)} trip,{" "} - {formatWindow(option.priority.windowHours - option.priority.tripHours)} to - spare in the {formatWindow(option.priority.windowHours)} window + Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "} + trip,{" "} + {formatGameHoursSpan( + option.priority.windowHours - option.priority.tripHours, + prefsCountdown, + )}{" "} + to spare in the{" "} + {formatGameHoursSpan(option.priority.windowHours, prefsCountdown)} window

)} {!blocked && option.duty?.willRest && ( diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx index 1442f26..2142493 100644 --- a/apps/web/src/screens/HQ.tsx +++ b/apps/web/src/screens/HQ.tsx @@ -10,6 +10,7 @@ import { LoanCard } from "../components/LoanCard"; import { RouteStrip } from "../components/RouteStrip"; import { formatCredits, locationName } from "../format"; import { useLoadedPlayer } from "../state/actions"; +import { usePreferences } from "../state/preferences"; import { buttonClass, Eyebrow, Icon, Panel, Screen, ScreenHeader, StatTile } from "../ui"; /** What the player needs to do next to reach the deal (decision 0.5.30). */ @@ -27,6 +28,7 @@ export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): s export function HQ() { const state = useLoadedPlayer(); const now = useClock((s) => s.now); + const clockFormat = usePreferences((p) => p.clock); const idle = state.ships.filter((s) => s.status === "idle").length; const flying = state.ships.filter((s) => s.status === "in-flight").length; const runsBeforeDeal = content.config.tutorialRunsBeforeDeal; @@ -56,7 +58,7 @@ export function HQ() { /> diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index a63673a..7ce3209 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -12,6 +12,7 @@ import type { MapSelection } from "../map/hitTest"; import { MapCanvas } from "../map/MapCanvas"; import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach"; import { useLoadedPlayer } from "../state/actions"; +import { usePreferences } from "../state/preferences"; import { Art, buttonClass, Icon } from "../ui"; const TYPE_LABELS: Record = { @@ -157,6 +158,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: ( /** A tapped ship in flight: its route, pilot and ETA. */ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) { const state = useLoadedPlayer(); + const clockFormat = usePreferences((p) => p.clock); const dispatch = state.activeDispatches.find((d) => d.id === dispatchId); if (!dispatch) return null; const shipName = dispatch.tutorial @@ -183,7 +185,7 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () {" "} - ({formatGameTime(realToGame(dispatch.arrivalAt, content.config))}) + ({formatGameTime(realToGame(dispatch.arrivalAt, content.config), clockFormat)})

); diff --git a/apps/web/src/state/preferences.ts b/apps/web/src/state/preferences.ts index 1dcd380..24aa612 100644 --- a/apps/web/src/state/preferences.ts +++ b/apps/web/src/state/preferences.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { create, type StoreApi, type UseBoundStore } from "zustand"; import type { Camera } from "../map/camera"; @@ -132,3 +133,16 @@ export const usePreferences = createPreferencesStore(); export function reducedMotionOn(prefs: Pick): boolean { return prefs.reducedMotion ?? deviceReducesMotion(); } + +/** + * Mirrors the reduced-motion override onto ``, which + * the stylesheet uses; no attribute means "follow the device". + */ +export function useMotionPreference(): void { + const override = usePreferences((p) => p.reducedMotion); + useEffect(() => { + const root = document.documentElement; + if (override === null) delete root.dataset.reducedMotion; + else root.dataset.reducedMotion = String(override); + }, [override]); +} diff --git a/apps/web/test/clock.test.tsx b/apps/web/test/clock.test.tsx index 7aa7f4d..4bf1464 100644 --- a/apps/web/test/clock.test.tsx +++ b/apps/web/test/clock.test.tsx @@ -52,7 +52,8 @@ describe("server-synced clock", () => { await syncClock(serverAhead(0)); const stop = startClockTicker(); render(); - const at = (real: number) => formatGameTime(realToGame(real, content.config)); + // The default clock follows the device locale (CR-011). + const at = (real: number) => formatGameTime(realToGame(real, content.config), "locale"); expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument(); // One real minute is one game hour. diff --git a/apps/web/test/contract-card.test.tsx b/apps/web/test/contract-card.test.tsx index ac1b5be..89b861d 100644 --- a/apps/web/test/contract-card.test.tsx +++ b/apps/web/test/contract-card.test.tsx @@ -1,6 +1,5 @@ import type { Contract, Ship } from "@deadhead/schema"; import { screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { realTimeOf } from "../src/components/Goods"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; @@ -78,9 +77,8 @@ describe("contract card (CR-007)", () => { const c = await card("3,210 cr"); // A Priority good can have its own name. expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument(); - const chip = within(c).getByRole("button", { name: /Priority · within 5h/ }); - await userEvent.click(chip); - expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`); + // The window follows the countdown preference: real time by default (CR-011). + expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`); expect(c).not.toHaveTextContent("Too slow"); }); diff --git a/apps/web/test/dispatch-priority.test.tsx b/apps/web/test/dispatch-priority.test.tsx index 82c646a..d6ff24c 100644 --- a/apps/web/test/dispatch-priority.test.tsx +++ b/apps/web/test/dispatch-priority.test.tsx @@ -4,8 +4,8 @@ import { contractTripGameHours, priorityWindow } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatGameHoursSpan } from "../src/clock/timeFormat"; import { useClock } from "../src/clock/clock"; -import { formatWindow } from "../src/components/Goods"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; const NOW = Date.parse("2100-01-01T00:00:00Z"); @@ -102,7 +102,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => { const fast = option(await openSheet(), "Quick Finch"); expect(within(fast).getByRole("radio")).toBeEnabled(); expect(within(fast).getByLabelText("Priority margin")).toHaveTextContent( - `Priority: ${formatWindow(trip("courier"))} trip, ${formatWindow(window - trip("courier"))} to spare in the ${formatWindow(window)} window`, + `Priority: ${formatGameHoursSpan(trip("courier"), "real")} trip, ${formatGameHoursSpan(window - trip("courier"), "real")} to spare in the ${formatGameHoursSpan(window, "real")} window`, ); }); @@ -112,7 +112,7 @@ describe("Priority in the dispatch sheet (CR-007)", () => { const slow = option(await openSheet(), "Old Barge"); expect(within(slow).getByRole("radio")).toBeDisabled(); expect(within(slow).getByRole("list", { name: "Problems" })).toHaveTextContent( - `Too slow — ${formatWindow(trip("bulk-hauler"))} vs ${formatWindow(window)} window`, + `Too slow — ${formatGameHoursSpan(trip("bulk-hauler"), "real")} vs ${formatGameHoursSpan(window, "real")} window`, ); }); }); diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index 36e927f..93abab7 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -134,7 +134,7 @@ describe("dispatch sheet", () => { expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`); expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`); expect(freighter).toHaveTextContent( - `ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config))}`, + `ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`, ); expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`); }); diff --git a/apps/web/test/display-options.test.tsx b/apps/web/test/display-options.test.tsx new file mode 100644 index 0000000..607244b --- /dev/null +++ b/apps/web/test/display-options.test.tsx @@ -0,0 +1,112 @@ +import { content } from "@deadhead/content"; +import type { Contract } from "@deadhead/schema"; +import { MS_PER_HOUR, realToGame } from "@deadhead/sim"; +import { act, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { + formatGameHoursSpan, + formatGameSpan, + formatGameTime, + formatSpan, + formatTimeOfDay, +} from "../src/clock/timeFormat"; +import { formatClock } from "../src/format"; +import { mapMotionStyle } from "../src/map/layout"; +import { mapStyle } from "../src/map/style"; +import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); +/** 4m 16s of real time is 4.27 game hours at 60× compression. */ +const SPAN = 4 * 60_000 + 16_000; + +describe("time and span helpers (Step 10K.3)", () => { + it("formats game time in 24-hour and 12-hour clocks", () => { + const t = Date.parse("2100-03-14T13:05:00Z"); + expect(formatGameTime(t, "24h")).toBe("2100-03-14 13:05"); + expect(formatGameTime(t, "12h")).toMatch(/^2100-03-14 1:05\sPM$/); + }); + + it("formats a real time of day in each clock mode", () => { + const t = new Date(2100, 0, 1, 15, 7).getTime(); + expect(formatTimeOfDay(t, "24h")).toBe("15:07"); + expect(formatTimeOfDay(t, "12h")).toMatch(/^0?3:07\sPM$/); + expect(formatTimeOfDay(t, "locale")).toBe( + new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + ); + }); + + it("formats spans as real time, game time or both", () => { + expect(formatSpan(SPAN, "real")).toBe("4m 16s"); + expect(formatSpan(SPAN, "game")).toBe("4.3 game h"); + expect(formatSpan(SPAN, "both")).toBe("4m 16s · 4.3 game h"); + expect(formatSpan(SPAN, "both", formatClock)).toBe("04:16 · 4.3 game h"); + expect(formatGameSpan(62.4)).toBe("62 game h"); + expect(formatGameHoursSpan(5, "game")).toBe("5.0 game h"); + expect(formatGameHoursSpan(5, "real")).toBe( + formatSpan((5 * MS_PER_HOUR) / content.config.timeCompression, "real"), + ); + }); + + it("stills the map background with reduced motion", () => { + expect(mapMotionStyle(true).parallax).toBe(0); + expect(mapMotionStyle(true).backgroundZoomFollow).toBe(0); + expect(mapMotionStyle(false).parallax).toBe(mapStyle.parallax); + }); +}); + +describe("display options in the app (Step 10K.3)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => { + vi.useRealTimers(); + act(() => usePreferences.getState().set(DEFAULT_PREFERENCES)); + }); + + it("switches the HQ clock to 24-hour", async () => { + act(() => usePreferences.getState().set({ clock: "12h" })); + renderApp(fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), "/"); + const game = realToGame(NOW, content.config); + expect(await screen.findByLabelText("Game clock")).toHaveTextContent( + formatGameTime(game, "12h"), + ); + act(() => usePreferences.getState().set({ clock: "24h" })); + expect(screen.getByLabelText("Game clock")).toHaveTextContent(formatGameTime(game, "24h")); + }); + + it("shows contract expiry in both real and game time when asked", async () => { + const job: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 10, + payout: 1234, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + SPAN, + firstContact: false, + }; + renderApp( + fakeApi({ + getPlayerState: () => samplePlayerState({ tutorial: null }), + getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }), + }), + "/contracts?location=earth", + ); + const card = (await screen.findByText("1,234 cr")).closest("li")!; + expect(card).toHaveTextContent("expires in 4m 16s"); + act(() => usePreferences.getState().set({ countdown: "both" })); + expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h"); + }); +}); diff --git a/apps/web/test/layout.test.tsx b/apps/web/test/layout.test.tsx index 4eda009..4560150 100644 --- a/apps/web/test/layout.test.tsx +++ b/apps/web/test/layout.test.tsx @@ -16,7 +16,8 @@ describe("app layout", () => { expect(await screen.findByRole("banner")).toBeInTheDocument(); expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr"); expect(screen.getByLabelText("Game clock")).toHaveTextContent( - /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/, + // Device-locale clock by default (CR-011): 24-hour or 12-hour. + /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}( [AP]M)?$/, ); const nav = screen.getByRole("navigation", { name: "Main" }); expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b1490de..35efb6d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -254,7 +254,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0 - [x] **Step 10K.1 — Client: device preferences store.** `state/preferences.ts`: `usePreferences` (Zustand) with clock (`locale`/`12h`/`24h`), countdown (`real`/`game`/`both`), reduced-motion override (null = follow the device), dismissed tips and saved map camera; `set`, `dismissTip`, `showTipsAgain`. Saved under `deadhead:preferences:v1`; loading validates each field and falls back to defaults; every read and write is wrapped. `reducedMotionOn(prefs)`. Test: `preferences.test.ts` (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory). - [x] **Step 10K.2 — Client: settings screen and entry point.** A gear link (`aria-label="Settings"`, new `gear` icon) at the right of the header opens `/settings` (a route inside the layout, not a nav tab). `screens/Settings.tsx`: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when `import.meta.env.PROD` is false. Remaining items arrive with their steps. Test: `settings.test.tsx`. -- [ ] Step 10K.3 — Client: display options applied everywhere +- [x] **Step 10K.3 — Client: display options applied everywhere.** `clock/timeFormat.ts`: `formatTimeOfDay` and `formatGameTime` follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); `formatSpan` / `formatGameHoursSpan` follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive `Countdown` (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets ``, which the stylesheet uses alongside the OS query, and `mapMotionStyle` zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: `display-options.test.tsx`; clock, layout, dispatch and contract-card tests updated for the new defaults. - [ ] Step 10K.4 — Client: reset map view and show tips again - [ ] Step 10K.5 — Server: rename company - [ ] Step 10K.6 — Server: start a new company