diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index b1fc1a0..9310bc1 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -11,7 +11,7 @@ import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet";
-import { HQ } from "./screens/HQ";
+import { Dashboard } from "./screens/Dashboard";
import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
@@ -26,7 +26,7 @@ import { Button } from "./ui";
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
- "/": ,
+ "/": ,
"/map": (
Loading map…
}>
diff --git a/apps/web/src/screens/Dashboard.tsx b/apps/web/src/screens/Dashboard.tsx
new file mode 100644
index 0000000..a7cd49c
--- /dev/null
+++ b/apps/web/src/screens/Dashboard.tsx
@@ -0,0 +1,449 @@
+import { content } from "@deadhead/content";
+import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
+import { dispatchProgress, realToGame } from "@deadhead/sim";
+import { Link } from "react-router";
+import { useClock } from "../clock/clock";
+import { formatGameTime } from "../clock/GameClock";
+import { Countdown } from "../components/Countdown";
+import { formatFu, FuelGauge } from "../components/Fuel";
+import { GoalsCard } from "../components/GoalsCard";
+import { goodName, PriorityChip } from "../components/Goods";
+import { LoanCard } from "../components/LoanCard";
+import { RouteStrip } from "../components/RouteStrip";
+import { formatCredits, formatGameHours, locationName, shipModelName } from "../format";
+import { lowFuel } from "../game";
+import { useLoadedPlayer } from "../state/actions";
+import { usePreferences } from "../state/preferences";
+import {
+ Art,
+ buttonClass,
+ Eyebrow,
+ Icon,
+ Meter,
+ Panel,
+ Pill,
+ Screen,
+ ScreenHeader,
+ StatTile,
+} from "../ui";
+
+/** What the player needs to do next to reach the deal (decision 0.5.30). */
+export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
+ if (tutorial.dealAvailable) return "Halvorsen is waiting at Earth with an offer.";
+ if (tutorial.runsCompleted < runsBeforeDeal) {
+ return "Keep flying — the old man has an offer for you.";
+ }
+ // Enough runs, but the deal is made at Earth.
+ return tutorial.runInFlight
+ ? "Halvorsen will talk once you land at Earth."
+ : "One more run: bring the ship back to Earth — Halvorsen is waiting there.";
+}
+
+/** The ship the player's own pilot flies (decision 0.5.119). */
+export type PilotShip =
+ | { kind: "loaner"; avatar: Pilot; locationId: string; dispatch: Dispatch | undefined }
+ | { kind: "ship"; avatar: Pilot; ship: Ship; dispatch: Dispatch | undefined }
+ | { kind: "none"; avatar: Pilot | undefined };
+
+/** The avatar's assigned ship, or the company loaner during the tutorial. */
+export function pilotShip(state: PlayerState): PilotShip {
+ const avatar = state.pilots.find((p) => p.isAvatar);
+ if (!avatar) return { kind: "none", avatar };
+ if (state.tutorial) {
+ return {
+ kind: "loaner",
+ avatar,
+ locationId: state.tutorial.loanerLocationId,
+ dispatch: state.activeDispatches.find((d) => d.tutorial),
+ };
+ }
+ const ship = state.ships.find((s) => s.id === avatar.assignedShipId);
+ if (!ship) return { kind: "none", avatar };
+ return {
+ kind: "ship",
+ avatar,
+ ship,
+ dispatch: state.activeDispatches.find((d) => d.id === ship.dispatchId),
+ };
+}
+
+/** The display name of the ship a dispatch flies (the loaner isn't in the ships list). */
+function flightShipName(state: PlayerState, dispatch: Dispatch): string {
+ if (dispatch.tutorial) return "Company loaner";
+ return state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "A ship";
+}
+
+/** The Dashboard ("Bridge"): your ship first, then the company (CR-020). */
+export function Dashboard() {
+ 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;
+ const onJobs = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt);
+ // Resting pilots and who wakes first (CR-004).
+ const resting = state.pilots
+ .filter((p) => p.resting && p.restEndsAt !== null)
+ .sort((a, b) => a.restEndsAt! - b.restEndsAt!);
+ // Ships in the upgrade shop, soonest out first (CR-009).
+ const inShop = state.ships
+ .filter((s) => s.inShopUntil !== null && now < s.inShopUntil)
+ .sort((a, b) => a.inShopUntil! - b.inShopUntil!);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {state.tutorial && (
+
+ Contract pilot
+
+ Flying for Halvorsen Lunar Freight
+
+
+ Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
+ {runsBeforeDeal}
+
+
+
+ {tutorialHint(state.tutorial, runsBeforeDeal)}
+
+ {!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
+
+
+ Fly a company run
+
+ )}
+
+ )}
+
+
+
+ Ships on jobs
+
+ {onJobs.length === 0 ? (
+ Nothing in flight.
+ ) : (
+
+ )}
+
+
+ {!state.tutorial && (
+
0 ? "default" : "muted"}>
+
+
+
+
+
+
+ Pilots resting: {resting.length}
+
+ {resting[0] ? (
+
+ {resting[0].name} wakes in{" "}
+
+
+ ) : (
+
Everyone is ready to fly.
+ )}
+
+
+
+ )}
+
+ {inShop.length > 0 && (
+
+
+ In the shop: {inShop.length}
+
+
+ {inShop.map((ship) => (
+ -
+ {ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
+ {ship.shop?.level} · ready in
+
+ ))}
+
+
+ )}
+
+ {!state.tutorial && state.ships.length === 0 && (
+
No ships yet — buy one in the Market.
+ )}
+
+
+
+ {!state.tutorial && }
+ {!state.tutorial && state.company.loanBalance > 0 && }
+
+
+
+ );
+}
+
+/** The player pilot's ship: status, trip, fuel and the pilot's duty (decision 0.5.119). */
+function YourShip({ className = "" }: { className?: string }) {
+ const state = useLoadedPlayer();
+ const now = useClock((s) => s.now);
+ const mine = pilotShip(state);
+
+ if (mine.kind === "none") {
+ return (
+
+ Your ship
+ You're not flying a ship right now.
+
+
+ Assign yourself on Pilots
+
+
+ );
+ }
+
+ const { avatar, dispatch } = mine;
+ const loaner = mine.kind === "loaner";
+ const ship = loaner ? null : mine.ship;
+ const modelId = loaner ? content.config.tutorialLoanerShipModelId : ship!.modelId;
+ const sprite = content.shipModel(modelId)?.sprite;
+ const name = loaner ? "Company loaner" : ship!.name;
+ const hereId = loaner ? mine.locationId : ship!.locationId;
+ const inShop = ship !== null && ship.inShopUntil !== null && now < ship.inShopUntil;
+ const discovered = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
+ const low = ship && !dispatch ? lowFuel(ship, discovered) : null;
+ const contract = dispatch?.contract ?? null;
+
+ return (
+
+
+ {sprite && (
+
+ )}
+
+
Your ship
+
+
{name}
+ {dispatch ? (
+
+ In flight
+
+ ) : inShop ? (
+
In the shop
+ ) : (
+
Idle
+ )}
+
+
{shipModelName(modelId)}
+
+ {dispatch
+ ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
+ : `At ${locationName(hereId ?? "")}`}
+
+
+
+
+ {dispatch && (
+
+
+
+
+ {contract
+ ? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
+ : "Flying empty"}
+
+
+ Arrives in
+
+
+ {contract?.priority && contract.deliveryWindowGameHours !== null && (
+
+ )}
+
+ )}
+
+ {inShop && (
+
+ In the shop — ready in
+
+ )}
+
+
+
+
Fuel
+ {ship ? (
+
+
+ {low && (
+
+
+ Low fuel
+ {" "}
+ · {locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
+
+ )}
+
+ ) : (
+
Supplied by Halvorsen
+ )}
+
+
+
+
+ {ship && !dispatch && !inShop && hereId && (
+
+
+
+ Find work here
+
+
+
+ Fleet
+
+
+ )}
+
+ );
+}
+
+/** The player pilot's duty, or their rest (CR-004). */
+function PilotDuty({ pilot }: { pilot: Pilot }) {
+ return (
+
+
+ {pilot.name} · Level {pilot.level}
+
+ {pilot.resting && pilot.restEndsAt !== null ? (
+
+
+ Resting · wakes in
+
+ ) : (
+ <>
+
= 0.85 ? "rose" : "amber"}
+ size="sm"
+ className="mt-2"
+ />
+
+ Duty {formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
+
+ >
+ )}
+
+ );
+}
+
+/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
+function Alerts() {
+ return (
+
+
+
+ Alerts
+
+ No alerts.
+ In-flight alerts and news will appear here.
+
+ );
+}
+
+/** One pip per company run toward the deal. */
+function RunPips({ done, total }: { done: number; total: number }) {
+ return (
+
+ {Array.from({ length: total }, (_, i) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx
deleted file mode 100644
index 353dea4..0000000
--- a/apps/web/src/screens/HQ.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { content } from "@deadhead/content";
-import type { TutorialState } from "@deadhead/schema";
-import { dispatchProgress, realToGame } from "@deadhead/sim";
-import { Link } from "react-router";
-import { useClock } from "../clock/clock";
-import { formatGameTime } from "../clock/GameClock";
-import { Countdown } from "../components/Countdown";
-import { GoalsCard } from "../components/GoalsCard";
-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). */
-export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string {
- if (tutorial.dealAvailable) return "Halvorsen is waiting at Earth with an offer.";
- if (tutorial.runsCompleted < runsBeforeDeal) {
- return "Keep flying — the old man has an offer for you.";
- }
- // Enough runs, but the deal is made at Earth.
- return tutorial.runInFlight
- ? "Halvorsen will talk once you land at Earth."
- : "One more run: bring the ship back to Earth — Halvorsen is waiting there.";
-}
-
-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;
- const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
- // Resting pilots and who wakes first (CR-004).
- const resting = state.pilots
- .filter((p) => p.resting && p.restEndsAt !== null)
- .sort((a, b) => a.restEndsAt! - b.restEndsAt!);
- // Ships in the upgrade shop, soonest out first (CR-009).
- const inShop = state.ships
- .filter((s) => s.inShopUntil !== null && now < s.inShopUntil)
- .sort((a, b) => a.inShopUntil! - b.inShopUntil!);
- const nextShip = next?.tutorial
- ? "ship-company-courier"
- : content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {state.tutorial && (
-
- Contract pilot
-
- Flying for Halvorsen Lunar Freight
-
-
- Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
- {runsBeforeDeal}
-
-
-
- {tutorialHint(state.tutorial, runsBeforeDeal)}
-
- {!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
-
-
- Fly a company run
-
- )}
-
- )}
-
-
-
- Next arrival
-
- {next ? (
- <>
-
-
-
-
-
- {locationName(next.originId)} → {locationName(next.destinationId)} in
-
-
-
-
-
- >
- ) : (
- Nothing in flight.
- )}
-
-
- {!state.tutorial && (
-
0 ? "default" : "muted"}>
-
-
-
-
-
-
- Pilots resting: {resting.length}
-
- {resting[0] ? (
-
- {resting[0].name} wakes in{" "}
-
-
- ) : (
-
Everyone is ready to fly.
- )}
-
-
-
- )}
-
- {inShop.length > 0 && (
-
-
- In the shop: {inShop.length}
-
-
- {inShop.map((ship) => (
- -
- {ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
- {ship.shop?.level} · ready in
-
- ))}
-
-
- )}
-
- {!state.tutorial && state.ships.length === 0 && (
-
No ships yet — buy one in the Market.
- )}
-
-
-
- {!state.tutorial && }
- {!state.tutorial && state.company.loanBalance > 0 && }
-
-
-
- );
-}
-
-/** One pip per company run toward the deal. */
-function RunPips({ done, total }: { done: number; total: number }) {
- return (
-
- {Array.from({ length: total }, (_, i) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/dashboard.test.tsx
similarity index 53%
rename from apps/web/test/hq.test.tsx
rename to apps/web/test/dashboard.test.tsx
index ec71e05..389fd7e 100644
--- a/apps/web/test/hq.test.tsx
+++ b/apps/web/test/dashboard.test.tsx
@@ -39,7 +39,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
resolvedAt: null,
});
-describe("HQ screen", () => {
+describe("Dashboard (CR-020)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
@@ -47,7 +47,7 @@ describe("HQ screen", () => {
});
afterEach(() => vi.useRealTimers());
- it("shows idle and in-flight counts and counts down to the next arrival", async () => {
+ it("shows idle and in-flight counts and every ship on a job, soonest first", async () => {
const state = samplePlayerState({
tutorial: null,
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
@@ -60,10 +60,13 @@ describe("HQ screen", () => {
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
- const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!;
- expect(within(next).getByText("1m 05s")).toBeInTheDocument();
+ const jobs = screen.getByRole("region", { name: "Ships on jobs" });
+ const rows = within(jobs).getAllByRole("listitem");
+ expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
+ expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
+ expect(rows[1]).toHaveTextContent("2m 05s");
act(() => vi.advanceTimersByTime(10_000));
- expect(within(next).getByText("55s")).toBeInTheDocument();
+ expect(rows[0]).toHaveTextContent("55s");
});
it("shows tutorial run progress toward the deal during the tutorial", async () => {
@@ -171,3 +174,126 @@ describe("loan on HQ", () => {
expect(dialog).toHaveTextContent("230 cr went to the lender");
});
});
+
+describe("your ship on the Dashboard (CR-020)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
+ vi.setSystemTime(NOW);
+ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
+ });
+ afterEach(() => vi.useRealTimers());
+
+ const avatar = samplePlayerState().pilots[0]!;
+ const mine = (overrides: Partial = {}): Ship => ({
+ ...ship("Iron Mule", "idle"),
+ pilotId: "avatar",
+ ...overrides,
+ });
+ const owner = (overrides: Partial) =>
+ samplePlayerState({
+ tutorial: null,
+ pilots: [{ ...avatar, assignedShipId: "Iron Mule", dutyHours: 3, dutyLimit: 12 }],
+ ...overrides,
+ });
+ const panel = async () => screen.findByRole("region", { name: "Your ship" });
+
+ it("shows an idle ship's place, fuel, the pilot's duty and where to find work", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
+ const p = await panel();
+ expect(p).toHaveTextContent("Iron Mule");
+ expect(p).toHaveTextContent("Idle");
+ expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
+ expect(within(p).getByRole("progressbar", { name: "Iron Mule fuel" })).toBeInTheDocument();
+ expect(within(p).getByRole("progressbar", { name: "Your duty" })).toHaveAttribute(
+ "aria-valuenow",
+ "3",
+ );
+ expect(within(p).getByRole("link", { name: "Find work here" })).toHaveAttribute(
+ "href",
+ "/contracts?location=earth",
+ );
+ expect(within(p).queryByLabelText("Your ship low fuel")).not.toBeInTheDocument();
+ });
+
+ it("warns when your ship is low on fuel", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine({ fuelFu: 1 })] }) }));
+ expect(await screen.findByLabelText("Your ship low fuel")).toHaveTextContent("Low fuel");
+ });
+
+ it("shows your ship's trip, cargo and arrival in flight", async () => {
+ const flight: Dispatch = {
+ ...dispatch("Iron Mule", NOW + 90_000),
+ contract: {
+ id: "c1",
+ originId: "earth",
+ destinationId: "moon",
+ cargoTypeId: "high-value",
+ goodId: "medical-supplies",
+ shipper: "Meridian Health",
+ priority: false,
+ deliveryWindowGameHours: null,
+ tier: 1,
+ massTonnes: 6,
+ payout: 900,
+ requiredSkill: { skillId: "expensive-cargo", level: 1 },
+ requiredLongHaulLevel: 0,
+ offeredAt: NOW,
+ expiresAt: NOW + 600_000,
+ firstContact: false,
+ },
+ };
+ const flying = mine({ status: "in-flight", locationId: null, dispatchId: flight.id });
+ renderApp(
+ fakeApi({ getPlayerState: () => owner({ ships: [flying], activeDispatches: [flight] }) }),
+ );
+ const p = await panel();
+ expect(p).toHaveTextContent("In flight");
+ expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("Earth → Moon");
+ expect(within(p).getByLabelText("Your cargo")).toHaveTextContent(
+ "6 t Medical Supplies for Meridian Health",
+ );
+ expect(p).toHaveTextContent("Arrives in 1m 30s");
+ });
+
+ it("shows a ship in the shop and a resting pilot", async () => {
+ const shopped = mine({ inShopUntil: NOW + 120_000 });
+ renderApp(
+ fakeApi({
+ getPlayerState: () =>
+ owner({
+ ships: [shopped],
+ pilots: [
+ { ...avatar, assignedShipId: "Iron Mule", resting: true, restEndsAt: NOW + 300_000 },
+ ],
+ }),
+ }),
+ );
+ const p = await panel();
+ expect(p).toHaveTextContent("In the shop — ready in 2m 00s");
+ expect(within(p).getByLabelText("Your pilot")).toHaveTextContent("Resting · wakes in 5m 00s");
+ });
+
+ it("says so when you aren't flying a ship", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
+ const p = await panel();
+ expect(p).toHaveTextContent("You're not flying a ship right now.");
+ expect(within(p).getByRole("link", { name: "Assign yourself on Pilots" })).toHaveAttribute(
+ "href",
+ "/pilots",
+ );
+ });
+
+ it("shows the company loaner during the tutorial", async () => {
+ renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
+ const p = await panel();
+ expect(p).toHaveTextContent("Company loaner");
+ expect(p).toHaveTextContent("Supplied by Halvorsen");
+ expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
+ });
+
+ it("has an Alerts panel, empty for now", async () => {
+ renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
+ const alerts = await screen.findByRole("region", { name: "Alerts" });
+ expect(alerts).toHaveTextContent("No alerts.");
+ });
+});
diff --git a/apps/web/test/dispatch-problems.test.tsx b/apps/web/test/dispatch-problems.test.tsx
index 64c3bdc..460887d 100644
--- a/apps/web/test/dispatch-problems.test.tsx
+++ b/apps/web/test/dispatch-problems.test.tsx
@@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
-import { tutorialHint } from "../src/screens/HQ";
+import { tutorialHint } from "../src/screens/Dashboard";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 998813b..e0281dd 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -453,3 +453,4 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C
Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions recorded as plan §0.5.118–0.5.121.
- [x] **Step 20.1 — Navigation: Bridge and HQs.** `SCREENS`: `/` is now "Dashboard" with the tab label "Bridge" and a new `helm` icon; `/hqs` ("HQs", HQ icon) sits after Pilots, with a first `HQs` screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below `sm` so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on `/`. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: `layout.test.tsx` (eight links, Bridge first and current on launch, HQs at `/hqs`; every screen's heading at its path).
- [x] **Step 20.2 — HQs screen.** `screens/HQs.tsx`: one emerald feature card per bay (labelled "HQ at "): location art and name, fuel price there (cheapest highlighted), ships at the location with Idle / In the shop, flights inbound to it with countdowns, unassigned pilots there (level, resting), and links to the location's contracts and to Pilots for hiring; the header says what an HQ allows (`HQ_ALLOWS`). No bays: a short explanation and "Lease one in Places". Leasing stays on Places. Test: `hqs-screen.test.tsx` (an HQ's ships, inbound flight, spare pilot, fuel price and links; ships elsewhere not listed; one card per HQ; empty state).
+- [x] **Step 20.3 — Dashboard.** `screens/HQ.tsx` → `screens/Dashboard.tsx` (`Dashboard`; `tutorialHint` moved with it). Leads with **Your ship** (`pilotShip(state)`: the avatar's assigned ship, the company loaner during the tutorial, or none): art, name and model, Idle / In flight / In the shop, where it is or its route with a route strip, cargo ("6 t Medical Supplies for Meridian Health" or "Flying empty"), arrival countdown and Priority chip, shop countdown, fuel gauge with the low-fuel warning (`lowFuel`, decision 0.5.113; "Supplied by Halvorsen" for the loaner), the avatar's duty meter or rest countdown, and "Find work here" / "Fleet" when idle; with no ship, "Assign yourself on Pilots". Beside it, **Alerts**: "No alerts. In-flight alerts and news will appear here." Then the stat tiles, the tutorial panel, **Ships on jobs** (every active dispatch, soonest first, with route, countdown and a trip meter; replaces "Next arrival"), pilot rest, in the shop, goals and the loan. Test: `dashboard.test.tsx` (was `hq.test.tsx`): ships on jobs in arrival order with live countdowns; your ship idle (place, fuel, duty, Find work here), low fuel, in flight with cargo and arrival, in the shop with a resting pilot, no ship, the tutorial loaner; the Alerts placeholder.