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