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:
parent
fec2c481df
commit
f948a22d8c
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue