Step 20.3: Dashboard with your ship, alerts and ships on jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 22:28:58 -04:00
parent fec2c481df
commit f948a22d8c
6 changed files with 584 additions and 194 deletions

View File

@ -11,7 +11,7 @@ import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts"; import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet"; import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ"; import { Dashboard } from "./screens/Dashboard";
import { HQs } from "./screens/HQs"; import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations"; import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market"; 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 MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = { const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
"/": <HQ />, "/": <Dashboard />,
"/map": ( "/map": (
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}> <Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
<MapScreen /> <MapScreen />

View File

@ -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 (
<Screen className="max-w-5xl">
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
<div className="grid gap-4 md:grid-cols-3">
<YourShip className="md:col-span-2" />
<Alerts />
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
label="Credits"
value={formatCredits(state.company.credits)}
icon="credits"
tone="amber"
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config), clockFormat)}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{state.tutorial && (
<Panel feature tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
Flying for Halvorsen Lunar Freight
</h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal}
</p>
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
{tutorialHint(state.tutorial, runsBeforeDeal)}
</p>
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
<Icon name="send" className="size-4" />
Fly a company run
</Link>
)}
</Panel>
)}
<Panel
as="section"
aria-labelledby="on-jobs-title"
tone={onJobs.length ? "cyan" : "default"}
>
<h2
id="on-jobs-title"
className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase"
>
Ships on jobs
</h2>
{onJobs.length === 0 ? (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
) : (
<ul className="mt-2 space-y-3">
{onJobs.map((d) => {
const name = flightShipName(state, d);
return (
<li key={d.id} aria-label={name} className="text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate">
<span className="font-semibold text-ink-50">{name}</span>{" "}
<span className="text-ink-300">
{locationName(d.originId)} → {locationName(d.destinationId)}
</span>
</span>
<span className="font-display font-bold whitespace-nowrap text-cyan-200">
<Countdown to={d.arrivalAt} />
</span>
</div>
<Meter
label={`${name} trip`}
value={Math.round(dispatchProgress(d, now) * 100)}
tone="cyan"
size="sm"
className="mt-1"
/>
</li>
);
})}
</ul>
)}
</Panel>
{!state.tutorial && (
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
<Icon name="rest" className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
Pilots resting: {resting.length}
</h2>
{resting[0] ? (
<p className="text-sm text-sky-200">
{resting[0].name} wakes in{" "}
<Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
)}
</div>
</div>
</Panel>
)}
{inShop.length > 0 && (
<Panel aria-label="In the shop" tone="amber">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
In the shop: {inShop.length}
</h2>
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
{inShop.map((ship) => (
<li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
</li>
))}
</ul>
</Panel>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
)}
</div>
<div className="space-y-4">
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div>
</div>
</Screen>
);
}
/** 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 (
<Panel as="section" aria-label="Your ship" feature className={className}>
<Eyebrow>Your ship</Eyebrow>
<p className="mt-2 text-sm text-ink-200">You&apos;re not flying a ship right now.</p>
<Link to="/pilots" className={`mt-3 ${buttonClass("secondary", "sm")}`}>
<Icon name="pilots" className="size-4" />
Assign yourself on Pilots
</Link>
</Panel>
);
}
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 (
<Panel
as="section"
aria-label="Your ship"
feature
tone={dispatch ? "cyan" : "default"}
className={`flex flex-col gap-3 ${className}`}
>
<div className="flex items-center gap-3">
{sprite && (
<div className="grid size-20 shrink-0 place-items-center rounded-xl bg-radial from-cyan-400/15 to-transparent to-70%">
<Art
sprite={sprite}
className={`size-18 drop-shadow-[0_6px_10px_rgb(0_0_0/0.7)] ${dispatch ? "rotate-45" : ""}`}
/>
</div>
)}
<div className="min-w-0 flex-1">
<Eyebrow>Your ship</Eyebrow>
<div className="flex items-start justify-between gap-2">
<h2 className="truncate font-display text-xl font-bold tracking-wide">{name}</h2>
{dispatch ? (
<Pill tone="cyan" icon="send">
In flight
</Pill>
) : inShop ? (
<Pill tone="amber">In the shop</Pill>
) : (
<Pill tone="emerald">Idle</Pill>
)}
</div>
<p className="text-xs text-ink-400">{shipModelName(modelId)}</p>
<p aria-label="Your ship's status" className="mt-1 text-sm text-ink-200">
{dispatch
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
: `At ${locationName(hereId ?? "")}`}
</p>
</div>
</div>
{dispatch && (
<div>
<RouteStrip
originId={dispatch.originId}
destinationId={dispatch.destinationId}
progress={dispatchProgress(dispatch, now)}
shipSprite={sprite}
/>
<div className="mt-1 flex flex-wrap items-center justify-between gap-2 text-sm">
<span aria-label="Your cargo" className="text-ink-200">
{contract
? `${contract.massTonnes} t ${goodName(contract)} for ${contract.shipper}`
: "Flying empty"}
</span>
<span className="font-display text-lg font-bold text-cyan-200">
Arrives in <Countdown to={dispatch.arrivalAt} />
</span>
</div>
{contract?.priority && contract.deliveryWindowGameHours !== null && (
<div className="mt-1">
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
</div>
)}
</div>
)}
{inShop && (
<p className="font-display text-sm text-amber-200">
In the shop — ready in <Countdown to={ship!.inShopUntil!} done="now" />
</p>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Eyebrow>Fuel</Eyebrow>
{ship ? (
<div className="mt-1">
<FuelGauge ship={ship} />
{low && (
<p
role="status"
aria-label="Your ship low fuel"
className="mt-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">
Low fuel
</span>{" "}
· {locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
</p>
)}
</div>
) : (
<p className="mt-1 text-sm text-ink-300">Supplied by Halvorsen</p>
)}
</div>
<PilotDuty pilot={avatar} />
</div>
{ship && !dispatch && !inShop && hereId && (
<div className="flex flex-wrap gap-2">
<Link
to={`/contracts?location=${hereId}`}
className={`${buttonClass("primary", "sm")} flex-1 basis-36`}
>
<Icon name="jobs" className="size-4" />
Find work here
</Link>
<Link to="/fleet" className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}>
<Icon name="fleet" className="size-4" />
Fleet
</Link>
</div>
)}
</Panel>
);
}
/** The player pilot's duty, or their rest (CR-004). */
function PilotDuty({ pilot }: { pilot: Pilot }) {
return (
<div aria-label="Your pilot">
<Eyebrow>
{pilot.name} · Level {pilot.level}
</Eyebrow>
{pilot.resting && pilot.restEndsAt !== null ? (
<p className="mt-1 flex items-center gap-1.5 text-sm text-sky-200">
<Icon name="rest" className="size-4 shrink-0" />
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</p>
) : (
<>
<Meter
label="Your duty"
value={Math.min(pilot.dutyHours, pilot.dutyLimit)}
max={pilot.dutyLimit}
tone={pilot.dutyHours / pilot.dutyLimit >= 0.85 ? "rose" : "amber"}
size="sm"
className="mt-2"
/>
<p className="mt-1 font-display text-xs text-ink-400">
Duty {formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</p>
</>
)}
</div>
);
}
/** Where in-flight alerts and news will appear; empty for now (decision 0.5.120). */
function Alerts() {
return (
<Panel as="section" aria-labelledby="alerts-title" tone="muted">
<h2
id="alerts-title"
className="flex items-center gap-2 font-display text-sm font-semibold tracking-wide text-ink-50"
>
<Icon name="alert" className="size-4 text-amber-300" />
Alerts
</h2>
<p className="mt-2 text-sm text-ink-300">No alerts.</p>
<p className="mt-1 text-xs text-ink-500">In-flight alerts and news will appear here.</p>
</Panel>
);
}
/** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) {
return (
<div aria-hidden="true" className="mt-2 flex gap-1.5">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
/>
))}
</div>
);
}

View File

@ -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 (
<Screen className="max-w-5xl">
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatTile
label="Credits"
value={formatCredits(state.company.credits)}
icon="credits"
tone="amber"
/>
<StatTile
label="Game date"
value={formatGameTime(realToGame(now, content.config), clockFormat)}
icon="clock"
/>
<StatTile label="Ships idle" value={String(idle)} icon="fleet" tone="emerald" />
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{state.tutorial && (
<Panel feature tone="amber">
<Eyebrow className="text-amber-300/80">Contract pilot</Eyebrow>
<h2 className="mt-0.5 font-display text-lg font-bold text-amber-200">
Flying for Halvorsen Lunar Freight
</h2>
<p aria-label="Company runs" className="mt-2 text-sm text-ink-200">
Company runs: {Math.min(state.tutorial.runsCompleted, runsBeforeDeal)} of{" "}
{runsBeforeDeal}
</p>
<RunPips done={state.tutorial.runsCompleted} total={runsBeforeDeal} />
<p aria-label="Next step" className="mt-2 text-sm text-ink-300">
{tutorialHint(state.tutorial, runsBeforeDeal)}
</p>
{!state.tutorial.runInFlight && !state.tutorial.dealAvailable && (
<Link to="/contracts" className={`mt-3 ${buttonClass("primary", "md", true)}`}>
<Icon name="send" className="size-4" />
Fly a company run
</Link>
)}
</Panel>
)}
<Panel feature tone={next ? "cyan" : "default"}>
<h2 className="font-display text-[11px] font-semibold tracking-[0.18em] text-cyan-300/80 uppercase">
Next arrival
</h2>
{next ? (
<>
<div className="mt-2">
<RouteStrip
originId={next.originId}
destinationId={next.destinationId}
progress={dispatchProgress(next, now)}
shipSprite={nextShip}
/>
</div>
<p className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-sm text-ink-200">
{locationName(next.originId)} → {locationName(next.destinationId)} in
</span>
<span className="font-display text-2xl font-bold text-cyan-200">
<Countdown to={next.arrivalAt} />
</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-ink-400">Nothing in flight.</p>
)}
</Panel>
{!state.tutorial && (
<Panel aria-label="Pilot rest" tone={resting.length > 0 ? "default" : "muted"}>
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-full bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/30">
<Icon name="rest" className="size-5" />
</span>
<div className="min-w-0">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
Pilots resting: {resting.length}
</h2>
{resting[0] ? (
<p className="text-sm text-sky-200">
{resting[0].name} wakes in{" "}
<Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="text-sm text-ink-400">Everyone is ready to fly.</p>
)}
</div>
</div>
</Panel>
)}
{inShop.length > 0 && (
<Panel aria-label="In the shop" tone="amber">
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
In the shop: {inShop.length}
</h2>
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
{inShop.map((ship) => (
<li key={ship.id}>
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
</li>
))}
</ul>
</Panel>
)}
{!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
)}
</div>
<div className="space-y-4">
{!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div>
</div>
</Screen>
);
}
/** One pip per company run toward the deal. */
function RunPips({ done, total }: { done: number; total: number }) {
return (
<div aria-hidden="true" className="mt-2 flex gap-1.5">
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={`h-2 flex-1 rounded-full ${i < done ? "bg-amber-400 shadow-glow-amber" : "bg-space-700"}`}
/>
))}
</div>
);
}

View File

@ -39,7 +39,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
resolvedAt: null, resolvedAt: null,
}); });
describe("HQ screen", () => { describe("Dashboard (CR-020)", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
@ -47,7 +47,7 @@ describe("HQ screen", () => {
}); });
afterEach(() => vi.useRealTimers()); 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({ const state = samplePlayerState({
tutorial: null, tutorial: null,
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")], 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 idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2"); expect(statValue("Ships in flight")).toHaveTextContent("2");
const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!; const jobs = screen.getByRole("region", { name: "Ships on jobs" });
expect(within(next).getByText("1m 05s")).toBeInTheDocument(); 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)); 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 () => { 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"); 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 => ({
...ship("Iron Mule", "idle"),
pilotId: "avatar",
...overrides,
});
const owner = (overrides: Partial<PlayerState>) =>
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.");
});
});

View File

@ -3,7 +3,7 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock"; 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"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z"); const NOW = Date.parse("2100-01-01T00:00:00Z");

View File

@ -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. 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.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 <place>"): 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.2 — HQs screen.** `screens/HQs.tsx`: one emerald feature card per bay (labelled "HQ at <place>"): 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.