From f948a22d8c3aa2b6568a8f5c4098c490c1b89e50 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 22:28:58 -0400 Subject: [PATCH] Step 20.3: Dashboard with your ship, alerts and ships on jobs Co-Authored-By: Claude Opus 5.5 --- apps/web/src/App.tsx | 4 +- apps/web/src/screens/Dashboard.tsx | 449 ++++++++++++++++++ apps/web/src/screens/HQ.tsx | 186 -------- .../test/{hq.test.tsx => dashboard.test.tsx} | 136 +++++- apps/web/test/dispatch-problems.test.tsx | 2 +- memory-bank/progress.md | 1 + 6 files changed, 584 insertions(+), 194 deletions(-) create mode 100644 apps/web/src/screens/Dashboard.tsx delete mode 100644 apps/web/src/screens/HQ.tsx rename apps/web/test/{hq.test.tsx => dashboard.test.tsx} (53%) 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.

+ ) : ( +
    + {onJobs.map((d) => { + const name = flightShipName(state, d); + return ( +
  • +
    + + {name}{" "} + + {locationName(d.originId)} → {locationName(d.destinationId)} + + + + + +
    + +
  • + ); + })} +
+ )} +
+ + {!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 ( + + ); +} 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 ( - - ); -} 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.