Step 25.4: client: Logbook screen
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-16 23:03:34 -04:00
parent 32b34a5d6c
commit cd6c4a292f
11 changed files with 591 additions and 150 deletions

View File

@ -1884,3 +1884,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **One level-up highlight per pilot**, at the highest level reached: a pilot who went from 1 to 3 reads "reached level 3". - **One level-up highlight per pilot**, at the highest level reached: a pilot who went from 1 to 3 reads "reached level 3".
- **View logbook also closes the pop-up**, so the absence it showed becomes `lastAbsence`, which is what the Logbook's "This absence" shows (Step 25.4). - **View logbook also closes the pop-up**, so the absence it showed becomes `lastAbsence`, which is what the Logbook's "This absence" shows (Step 25.4).
- **Seen while building it:** pilot names are drawn without checking the roster, so two hired pilots can share a name (two "Dmitri Brennan" in the screenshot run). That's older than this CR and isn't changed here. - **Seen while building it:** pilot names are drawn without checking the roster, so two hired pilots can share a name (two "Dmitri Brennan" in the screenshot run). That's older than this CR and isn't changed here.
## Step 25.4
- **The Logbook isn't a tab**: the navigation already has eight. It opens from "While you were away" and from a card on the Bridge, like Settings opens from the gear.
- **Every pilot, the player included**: the CR-021 logbook showed only Independent pilots' own trips, beside cards for the player's. The Logbook has everything, so the player's trips are in the Captain's section and goals in a "Company" section.
- **Newest first, 50 lines a pilot at a time**: 7 days of a busy fleet runs to hundreds of lines per pilot.
- **Times show the date on earlier days**, judged by the game clock, so "Last 7 days" reads clearly.
- **"This absence" after the pop-up closes is the last absence** (`lastAbsence`): View logbook closes the pop-up, and the Logbook still shows what it summarised. Its net and deliveries are the pop-up's (the test checks both).

View File

@ -17,6 +17,7 @@ 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";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
import { Logbook } from "./screens/Logbook";
import { Settings } from "./screens/Settings"; import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences"; import { useMotionPreference } from "./state/preferences";
import { StartScreen } from "./screens/StartScreen"; import { StartScreen } from "./screens/StartScreen";
@ -113,6 +114,8 @@ export function App() {
))} ))}
{/* Settings opens from the header gear, not a tab (CR-011). */} {/* Settings opens from the header gear, not a tab (CR-011). */}
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
{/* The Logbook opens from "While you were away" and the Bridge (CR-025). */}
<Route path="/logbook" element={<Logbook />} />
</Route> </Route>
</Routes> </Routes>
<AwaySummary /> <AwaySummary />

View File

@ -1,40 +1,23 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { PlayerState, ResolutionSummary } from "@deadhead/schema"; import type { LogEntry, PlayerState } from "@deadhead/schema";
import { flightNet } from "@deadhead/sim";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useClock } from "../clock/clock";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet"; import { formatLocalTime } from "../screens/DispatchSheet";
import { Icon } from "../ui"; import { Button, Icon } from "../ui";
import { independentStatus } from "./IndependentPilots"; import { pilotNow } from "./AwaySummary";
import { formatSleepTime } from "./FleetSleep";
/** One line of a pilot's logbook (CR-021 §2). */
export type LogEntry =
| {
kind: "job";
dispatchId: string;
good: string;
originId: string;
destinationId: string;
/** The contract's payout: what the company kept, the pilot's share and the lender's cut. */
payout: number;
pilotShare: number;
fuelCost: number;
xp: number;
}
| {
kind: "deadhead";
dispatchId: string;
originId: string;
destinationId: string;
fuelCost: number;
}
| { kind: "rest"; dispatchId: string; until: number };
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
export interface PilotLog { export interface PilotLog {
pilotId: string; /** Null for the company: goals, and anything without a pilot. */
pilotId: string | null;
name: string; name: string;
/** Newest first. */
entries: LogEntry[]; entries: LogEntry[];
xp: number; xp: number;
/** Where they are or what they're doing now. */ /** Where they are or what they're doing now; empty for the company. */
now: string; now: string;
} }
@ -45,139 +28,222 @@ export interface Logbook {
payout: number; payout: number;
pilotShare: number; pilotShare: number;
fuelCost: number; fuelCost: number;
loanRepaid: number;
/** Payout less pilot shares, fuel used and the loan (decision 0.5.142). */
net: number;
xp: number; xp: number;
}; };
pilots: PilotLog[]; pilots: PilotLog[];
} }
/** /**
* The pilot logbook (CR-021 §2): Independent pilots' own trips since the * The logbook for some entries (decision 0.5.141): by pilot in roster order,
* player last looked, by pilot in roster order, oldest first, with company * then the company's own, newest first, with totals that are the sums of the
* totals that are the sums of the entries. * entries. Pilots with no entries are left out.
*/ */
export function pilotLogbook(state: PlayerState, summaries: readonly ResolutionSummary[]): Logbook { export function pilotLogbook(state: PlayerState, entries: readonly LogEntry[]): Logbook {
const own = summaries.filter((s) => s.independent).sort((a, b) => a.resolvedAt - b.resolvedAt); const newest = [...entries].sort((a, b) => b.at - a.at || (a.id < b.id ? 1 : -1));
const known = new Set(state.pilots.map((p) => p.id));
const pilots: PilotLog[] = []; const pilots: PilotLog[] = [];
const xpOf = (list: LogEntry[]) =>
list.reduce((sum, e) => sum + (e.kind === "flight" ? e.xp : 0), 0);
for (const pilot of state.pilots) { for (const pilot of state.pilots) {
const trips = own.filter((s) => s.pilotId === pilot.id); const own = newest.filter((e) => e.pilotId === pilot.id);
if (trips.length === 0) continue; if (own.length === 0) continue;
const entries: LogEntry[] = [];
for (const s of trips) {
if (s.deadhead) {
entries.push({
kind: "deadhead",
dispatchId: s.dispatchId,
originId: s.originId,
destinationId: s.destinationId,
fuelCost: s.fuelCost,
});
} else {
entries.push({
kind: "job",
dispatchId: s.dispatchId,
good: (s.goodId && content.good(s.goodId)?.name) || "Cargo",
originId: s.originId,
destinationId: s.destinationId,
payout: s.credits + s.pilotPay + s.loanRepayment,
pilotShare: s.pilotPay,
fuelCost: s.fuelCost,
xp: s.pilotXp,
});
}
if (s.restEndsAt !== null) {
entries.push({ kind: "rest", dispatchId: s.dispatchId, until: s.restEndsAt });
}
}
pilots.push({ pilots.push({
pilotId: pilot.id, pilotId: pilot.id,
name: pilot.name, name: pilot.name,
entries, entries: own,
xp: trips.reduce((sum, s) => sum + s.pilotXp, 0), xp: xpOf(own),
now: now: pilotNow(state, pilot),
independentStatus(state, pilot) ??
(pilot.locationId ? `At ${locationName(pilot.locationId)}` : "In flight"),
}); });
} }
const all = pilots.flatMap((p) => p.entries); const company = newest.filter((e) => e.pilotId === null || !known.has(e.pilotId));
const jobs = all.filter((e) => e.kind === "job"); if (company.length > 0) {
const deadheads = all.filter((e) => e.kind === "deadhead"); pilots.push({ pilotId: null, name: "Company", entries: company, xp: 0, now: "" });
}
const flights = entries.filter(
(e): e is Extract<LogEntry, { kind: "flight" }> => e.kind === "flight",
);
const jobs = flights.filter((f) => !f.deadhead);
return { return {
totals: { totals: {
jobs: jobs.length, jobs: jobs.length,
deadheads: deadheads.length, deadheads: flights.length - jobs.length,
payout: jobs.reduce((sum, e) => sum + e.payout, 0), payout: jobs.reduce((sum, f) => sum + f.payout, 0),
pilotShare: jobs.reduce((sum, e) => sum + e.pilotShare, 0), pilotShare: flights.reduce((sum, f) => sum + f.pilotPay, 0),
fuelCost: [...jobs, ...deadheads].reduce((sum, e) => sum + e.fuelCost, 0), fuelCost: flights.reduce((sum, f) => sum + f.fuelCost, 0),
xp: pilots.reduce((sum, p) => sum + p.xp, 0), loanRepaid: flights.reduce((sum, f) => sum + f.loanRepayment, 0),
net: flights.reduce((sum, f) => sum + flightNet(f), 0),
xp: xpOf(flights),
}, },
pilots, pilots,
}; };
} }
/** The pilot logbook on the "while you were away" sheet. */ const REFUEL_SOURCE = {
export function PilotLogbook({ logbook }: { logbook: Logbook }) { "top-up": "Auto top-up",
if (logbook.pilots.length === 0) return null; bought: "bought",
pilot: "bought by the pilot",
emergency: "emergency fuel, on the loan",
} as const;
const shipName = (state: PlayerState, id: string | null) =>
state.ships.find((s) => s.id === id)?.name ?? "A ship";
/** One logbook line, with every figure (CR-021 §2's detail, decision 0.5.141). */
function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
const route = (from: string, to: string) => (
<span className="text-ink-400">
{locationName(from)} → {locationName(to)}
</span>
);
const fuel = (cost: number) => (
<span className="text-amber-200/90">fuel {formatCredits(cost)}</span>
);
switch (entry.kind) {
case "flight":
return entry.deadhead ? (
<>
<Icon name="send" className="size-4 text-cyan-300" />
<span>Deadhead</span>
{route(entry.originId, entry.destinationId)}
{fuel(entry.fuelCost)}
</>
) : (
<>
<span className="font-semibold">
{(entry.goodId && content.good(entry.goodId)?.name) || "Cargo"}
</span>
{route(entry.originId, entry.destinationId)}
<span className="text-emerald-300">{formatCredits(entry.payout)}</span>
{entry.pilotPay > 0 && (
<span className="text-ink-400">share {formatCredits(entry.pilotPay)}</span>
)}
{fuel(entry.fuelCost)}
{entry.loanRepayment > 0 && (
<span className="text-ink-400">lender {formatCredits(entry.loanRepayment)}</span>
)}
{entry.xp > 0 && <span className="text-violet-300">+{entry.xp} XP</span>}
</>
);
case "refuel":
return (
<span className="text-amber-200/90">
{Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
{formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
</span>
);
case "rest":
return (
<span className="flex items-center gap-1 text-sky-300">
<Icon name="rest" className="size-4" />
Rested until {formatLocalTime(entry.until)}
</span>
);
case "upgrade":
return (
<span className="text-cyan-100">
{shipName(state, entry.shipId)}:{" "}
{content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
</span>
);
case "discovery":
return <span className="text-cyan-200">Discovered {locationName(entry.locationId)}</span>;
case "level-up":
return (
<span className="text-amber-200">
Reached level {entry.level} · {entry.skillPoints}{" "}
{entry.skillPoints === 1 ? "skill point" : "skill points"}
</span>
);
case "goal-ready":
return (
<span className="text-amber-200">
Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
</span>
);
}
}
/** A line's time: the time of day today, with the date on earlier days. */
function entryTime(at: number, now: number): string {
return new Date(at).toDateString() === new Date(now).toDateString()
? formatLocalTime(at)
: formatSleepTime(at);
}
/** How many lines a pilot's log shows before "Show more". */
const PAGE = 50;
function PilotSection({ log, state, open }: { log: PilotLog; state: PlayerState; open: boolean }) {
const [shown, setShown] = useState(PAGE);
const now = useClock((c) => c.now);
return (
<details open={open} className="rounded-xl border border-space-600 bg-space-950/50 p-3">
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">{log.name}</span>
{log.xp > 0 && <span className="text-xs text-violet-300">+{log.xp} XP</span>}
</summary>
{log.now && (
<p aria-label={`${log.name} now`} className="mt-1 text-cyan-100">
{log.now}
</p>
)}
<ul aria-label={`${log.name} log`} className="mt-2 space-y-1 text-ink-200">
{log.entries.slice(0, shown).map((e) => (
<li key={e.id} className="flex flex-wrap items-baseline gap-x-2">
<time className="text-xs text-ink-500 tabular-nums">{entryTime(e.at, now)}</time>
<EntryLine entry={e} state={state} />
</li>
))}
</ul>
{log.entries.length > shown && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setShown(shown + PAGE)}
>
Show more ({log.entries.length - shown})
</Button>
)}
</details>
);
}
/** The logbook's figures and its pilots' sections (decision 0.5.141). */
export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: PlayerState }) {
const { totals } = logbook; const { totals } = logbook;
return ( return (
<section aria-label="Pilot logbook" className="mt-4 space-y-3 text-sm"> <section aria-label="Pilot logbook" className="space-y-3 text-sm">
<div <div
aria-label="Company totals" aria-label="Logbook totals"
className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-3" className="grid grid-cols-2 gap-x-3 gap-y-1 rounded-xl border border-cyan-400/40 bg-cyan-950/30 p-3 sm:grid-cols-4"
> >
<span>{totals.jobs} jobs</span> <span>{totals.jobs} jobs</span>
<span>{totals.deadheads} deadheads</span> <span>{totals.deadheads} deadheads</span>
<span className="text-emerald-300">{formatCredits(totals.payout)} paid</span> <span className="text-emerald-300">{formatCredits(totals.payout)} paid</span>
<span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span> <span className="text-ink-300">{formatCredits(totals.pilotShare)} pilot shares</span>
<span className="text-amber-200">{formatCredits(totals.fuelCost)} fuel</span> <span className="text-amber-200">{formatCredits(totals.fuelCost)} fuel</span>
<span className="text-ink-300">{formatCredits(totals.loanRepaid)} to the lender</span>
<span className={totals.net < 0 ? "text-rose-300" : "text-emerald-300"}>
{formatCredits(totals.net)} net
</span>
<span className="text-violet-300">+{totals.xp} XP</span> <span className="text-violet-300">+{totals.xp} XP</span>
</div> </div>
{logbook.pilots.map((pilot) => ( {logbook.pilots.length === 0 ? (
<details <p className="text-ink-400">Nothing logged in this time.</p>
key={pilot.pilotId} ) : (
logbook.pilots.map((log) => (
<PilotSection
key={log.pilotId ?? "company"}
log={log}
state={state}
open={logbook.pilots.length === 1} open={logbook.pilots.length === 1}
className="rounded-xl border border-space-600 bg-space-950/50 p-3" />
> ))
<summary className="flex cursor-pointer items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">{pilot.name}</span>
<span className="text-xs text-violet-300">+{pilot.xp} XP</span>
</summary>
<p aria-label={`${pilot.name} now`} className="mt-1 text-cyan-100">
{pilot.now}
</p>
<ul aria-label={`${pilot.name} log`} className="mt-2 space-y-1 text-ink-200">
{pilot.entries.map((e) => (
<li key={`${e.kind}:${e.dispatchId}`} className="flex flex-wrap gap-x-2">
{e.kind === "job" && (
<>
<span className="font-semibold">{e.good}</span>
<span className="text-ink-400">
{locationName(e.originId)} → {locationName(e.destinationId)}
</span>
<span className="text-emerald-300">{formatCredits(e.payout)}</span>
<span className="text-ink-400">share {formatCredits(e.pilotShare)}</span>
<span className="text-amber-200/90">fuel {formatCredits(e.fuelCost)}</span>
</>
)} )}
{e.kind === "deadhead" && (
<>
<Icon name="send" className="size-4 text-cyan-300" />
<span>
Deadhead {locationName(e.originId)} → {locationName(e.destinationId)}
</span>
<span className="text-amber-200/90">fuel {formatCredits(e.fuelCost)}</span>
</>
)}
{e.kind === "rest" && (
<span className="flex items-center gap-1 text-sky-300">
<Icon name="rest" className="size-4" />
Rested until {formatLocalTime(e.until)}
</span>
)}
</li>
))}
</ul>
</details>
))}
</section> </section>
); );
} }

View File

@ -222,6 +222,23 @@ export function Dashboard() {
<div className="space-y-4"> <div className="space-y-4">
<DispatchOfficePanel /> <DispatchOfficePanel />
{!state.tutorial && (
<Panel className="text-sm" aria-label="Logbook">
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-ink-100">
<Icon name="route" className="size-4 text-cyan-300" />
Logbook
</p>
<p className="mt-1 text-ink-300">
Every job, refuel, rest and upgrade from the last 7 days, pilot by pilot.
</p>
<Link
to="/logbook"
className="mt-2 inline-block font-semibold text-amber-300 underline"
>
Open the Logbook
</Link>
</Panel>
)}
{!state.tutorial && <GoalsCard />} {!state.tutorial && <GoalsCard />}
{!state.tutorial && state.company.loanBalance > 0 && <LoanCard />} {!state.tutorial && state.company.loanBalance > 0 && <LoanCard />}
</div> </div>

View File

@ -0,0 +1,101 @@
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
import { logbookKey } from "../components/AwaySummary";
import { PilotLogbook, pilotLogbook } from "../components/PilotLogbook";
import { useLoadedPlayer } from "../state/actions";
import { Panel, Screen, ScreenHeader } from "../ui";
const HOUR = 3_600_000;
export const RANGES = [
["absence", "This absence"],
["24h", "Last 24 h"],
["7d", "Last 7 days"],
] as const;
export type LogRange = (typeof RANGES)[number][0];
/**
* The Logbook (decision 0.5.141): every entry of the last 7 days, per pilot,
* filtered by pilot and time range. "This absence" is the one the summary
* covers: the one open now if it's still showing, else the last one closed.
*/
export function Logbook() {
const api = useApi();
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const [params, setParams] = useSearchParams();
const range = (RANGES.find(([value]) => value === params.get("range"))?.[0] ?? "24h") as LogRange;
const pilotId = params.get("pilot") ?? "";
const book = useQuery({
queryKey: [...logbookKey, "screen", state.summaries.length],
queryFn: () => api.call("getLogbook", {}),
});
const set = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
};
let body = <p className="text-ink-400">Reading the logbook…</p>;
if (book.data) {
const pending = state.summaries.length > 0;
const window =
range === "absence"
? pending
? book.data.absence
: (book.data.lastAbsence ?? book.data.absence)
: { from: now - (range === "24h" ? 24 : 7 * 24) * HOUR, to: now };
const entries = book.data.entries.filter(
(e) => e.at > window.from && e.at <= window.to && (pilotId === "" || e.pilotId === pilotId),
);
body = <PilotLogbook logbook={pilotLogbook(state, entries)} state={state} />;
}
return (
<Screen className="max-w-3xl">
<ScreenHeader title="Logbook" eyebrow="The last 7 days" icon="route" />
<Panel className="flex flex-wrap items-end gap-3 text-sm" aria-label="Logbook filters">
<label className="flex flex-col gap-1">
<span className="text-ink-400">Pilot</span>
<select
value={pilotId}
onChange={(e) => set("pilot", e.target.value)}
className="rounded-lg border border-space-600 bg-space-900 px-2 py-1.5"
>
<option value="">All pilots</option>
{state.pilots.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<fieldset className="flex flex-col gap-1">
<legend className="text-ink-400">Time</legend>
<div className="flex overflow-hidden rounded-lg border border-space-600">
{RANGES.map(([value, label]) => (
<label
key={value}
className="cursor-pointer px-3 py-1.5 has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
>
<input
type="radio"
name="logbook-range"
value={value}
checked={range === value}
onChange={() => set("range", value)}
className="sr-only"
/>
{label}
</label>
))}
</div>
</fieldset>
</Panel>
{body}
</Screen>
);
}

View File

@ -4,7 +4,7 @@ import { act, render, screen, within } from "@testing-library/react";
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 { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook"; import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook";
import { pilotExtras } from "./fixtures"; import { logbookFrom, pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const PERIOD_START = Date.parse("2100-01-01T00:00:00Z"); const PERIOD_START = Date.parse("2100-01-01T00:00:00Z");
@ -195,30 +195,32 @@ describe("pilot logbook (Step 21.7)", () => {
summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }), summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }),
]; ];
it("lists each pilot's jobs, deadheads, rests and XP, and where they are now", () => { // The logbook is built from entries now (CR-025): these stand for the summaries above.
const book = pilotLogbook(owner([ada, bo]), summaries); const entries = () => logbookFrom({ ...owner([ada, bo]), summaries }).entries;
expect(book.pilots.map((p) => p.name)).toEqual(["Ada", "Bo"]);
const [a, b] = book.pilots; it("lists each pilot's jobs, deadheads, rests and XP, newest first, and where they are now", () => {
expect(a!.entries.map((e) => e.kind)).toEqual(["job", "deadhead"]); const book = pilotLogbook(owner([ada, bo]), entries());
expect(a!.entries[0]).toMatchObject({ good: "Consumer Goods", payout: 1_000, pilotShare: 200 }); expect(book.pilots.map((p) => p.name)).toEqual(["Captain", "Ada", "Bo"]);
const [, a, b] = book.pilots;
expect(a!.entries.map((e) => e.kind)).toEqual(["flight", "flight"]);
expect(a!.entries[1]).toMatchObject({ goodId: "consumer-goods", payout: 1_000, pilotPay: 200 });
expect(a!.xp).toBe(10); expect(a!.xp).toBe(10);
expect(a!.now).toBe("Waiting for new contracts at Earth"); expect(a!.now).toBe("At Earth");
expect(b!.entries.map((e) => e.kind)).toEqual(["job", "rest"]); expect(b!.entries.map((e) => e.kind)).toEqual(["rest", "flight"]);
expect(b!.now).toBe("Waiting for new contracts at Moon"); expect(b!.now).toBe("At Moon");
}); });
it("totals the entries for the company", () => { it("totals the entries for the company", () => {
const book = pilotLogbook(owner([ada, bo]), summaries); const book = pilotLogbook(owner([ada, bo]), entries());
const entries = book.pilots.flatMap((p) => p.entries);
const jobs = entries.filter((e) => e.kind === "job");
expect(book.totals).toEqual({ expect(book.totals).toEqual({
jobs: 2, jobs: 3,
deadheads: 1, deadheads: 1,
payout: jobs.reduce((s, e) => s + (e.kind === "job" ? e.payout : 0), 0), payout: 1_000 + 1_200 + 1_000,
pilotShare: 500, pilotShare: 200 + 300 + 200,
fuelCost: 100 + 40 + 100, fuelCost: 100 + 40 + 100 + 100,
xp: 22, loanRepaid: 200 + 200,
net: 1_000 + 1_200 + 1_000 - 700 - 340 - 400,
xp: 32,
}); });
expect(book.totals.payout).toBe(1_000 + 1_200);
}); });
}); });

View File

@ -0,0 +1,244 @@
import type { LogbookResponse, LogEntry, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { screen, waitFor, 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 { formatLocalTime } from "../src/screens/DispatchSheet";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-08T12:00:00Z");
const HOUR = 3_600_000;
const ABSENCE = { from: NOW - 5 * HOUR, to: NOW };
const pilot = (id: string, name: string): Pilot => ({
...samplePlayerState().pilots[0]!,
id,
name,
isAvatar: false,
assignedShipId: `s-${id}`,
locationId: "earth",
...pilotExtras,
mode: "independent",
});
const ship = (id: string, name: string, pilotId: string): Ship => ({
id,
modelId: "courier",
name,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId,
});
let n = 0;
const at = (hoursAgo: number) => NOW - hoursAgo * HOUR + n++;
const job = (pilotId: string, hoursAgo: number, over: Partial<LogEntry> = {}): LogEntry =>
({
id: `j${n}`,
at: at(hoursAgo),
pilotId,
shipId: `s-${pilotId}`,
kind: "flight",
dispatchId: `d${n}`,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "medical-supplies",
massTonnes: 6,
shipper: "Meridian Health",
payout: 1_000,
pilotPay: 150,
fuelCost: 60,
loanRepayment: 90,
xp: 12,
scriptedId: null,
...over,
}) as LogEntry;
const restUntil = NOW - HOUR;
const entries: LogEntry[] = [
job("ada", 2),
job("ada", 1, {
deadhead: true,
goodId: null,
payout: 0,
pilotPay: 0,
loanRepayment: 0,
xp: 0,
fuelCost: 40,
}),
{ id: "r1", at: at(2), pilotId: "ada", shipId: "s-ada", kind: "rest", until: restUntil },
{
id: "f1",
at: at(3),
pilotId: "ada",
shipId: "s-ada",
kind: "refuel",
locationId: "earth",
boughtFu: 12,
cost: 96,
source: "pilot",
},
{
id: "u1",
at: at(4),
pilotId: "bo",
shipId: "s-bo",
kind: "upgrade",
upgradeId: "engines",
level: 2,
},
{ id: "x1", at: at(4), pilotId: "bo", shipId: "s-bo", kind: "discovery", locationId: "mars" },
{
id: "l1",
at: at(4),
pilotId: "bo",
shipId: "s-bo",
kind: "level-up",
level: 3,
skillPoints: 1,
},
job("bo", 4),
// Before this absence, inside the last 24 hours.
job("bo", 20),
// Older than a day.
job("ada", 100),
{
id: "g1",
at: at(1),
pilotId: null,
shipId: null,
kind: "goal-ready",
goalId: "first-own-delivery",
},
];
const pending: ResolutionSummary = {
dispatchId: "d-pending",
shipId: "s-ada",
pilotId: "ada",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 60,
topUp: null,
goodId: "medical-supplies",
massTonnes: 6,
shipper: "Meridian Health",
credits: 760,
loanRepayment: 90,
pilotPay: 150,
pilotXp: 12,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: NOW - HOUR,
};
/** Ada and Bo flying Independent; the summary still open unless `seen`. */
function app(seen = true) {
let closed = seen;
const state = () =>
samplePlayerState({
tutorial: null,
pilots: [samplePlayerState().pilots[0]!, pilot("ada", "Ada"), pilot("bo", "Bo")],
ships: [ship("s-ada", "Kite", "ada"), ship("s-bo", "Wren", "bo")],
summaries: closed ? [] : [pending],
});
const book = (): LogbookResponse => ({
entries,
absence: closed ? { from: NOW, to: NOW } : ABSENCE,
lastAbsence: closed ? ABSENCE : null,
problems: [],
});
return fakeApi({
getPlayerState: state,
getLogbook: book,
acknowledgeSummaries: () => {
closed = true;
return { acknowledged: 1 };
},
});
}
const log = (name: string) => screen.findByRole("list", { name: `${name} log` });
describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows each pilot's jobs with payout, share, fuel, lender and XP, and deadheads, refuels and rests", async () => {
renderApp(app(), "/logbook?range=24h");
const ada = await log("Ada");
const lines = within(ada)
.getAllByRole("listitem")
.map((li) => li.textContent);
// Newest first.
expect(lines).toEqual([
expect.stringContaining("DeadheadEarth → Moonfuel 40 cr"),
// The rest came with the job on landing, so it is the newer line.
expect.stringContaining(`Rested until ${formatLocalTime(restUntil)}`),
expect.stringContaining(
"Medical SuppliesEarth → Moon1,000 crshare 150 crfuel 60 crlender 90 cr+12 XP",
),
expect.stringContaining("12 fu at Earth, 96 cr (bought by the pilot)"),
]);
const bo = await log("Bo");
expect(bo).toHaveTextContent("Wren: Engine Tuning level 2 finished");
expect(bo).toHaveTextContent("Discovered Mars");
expect(bo).toHaveTextContent("Reached level 3 · 1 skill point");
expect(await log("Company")).toHaveTextContent("Goal ready:");
});
it("narrows to one pilot and to a time range", async () => {
renderApp(app(), "/logbook?range=24h");
await log("Ada");
const totals = () => screen.getByLabelText("Logbook totals");
// Last 24 h: Ada's job and deadhead, Bo's two jobs.
expect(totals()).toHaveTextContent("3 jobs1 deadheads");
await userEvent.click(screen.getByRole("radio", { name: "Last 7 days" }));
await waitFor(() => expect(totals()).toHaveTextContent("4 jobs1 deadheads"));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Pilot" }), "bo");
await waitFor(() => expect(screen.queryByRole("list", { name: "Ada log" })).toBeNull());
expect(totals()).toHaveTextContent("2 jobs0 deadheads");
expect(screen.queryByRole("list", { name: "Company log" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "This absence" }));
await waitFor(() => expect(totals()).toHaveTextContent("1 jobs0 deadheads"));
});
it("opens from View logbook on This absence, with the pop-up's totals", async () => {
renderApp(app(false), "/");
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
const figures = await within(popup).findByLabelText("Company totals");
const net = within(figures).getByText(/^[+−][\d,]+ cr$/).textContent!;
expect(figures).toHaveTextContent("Deliveries2");
await userEvent.click(within(popup).getByRole("button", { name: "View logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "This absence" })).toBeChecked();
const totals = await screen.findByLabelText("Logbook totals");
await waitFor(() => expect(totals).toHaveTextContent("2 jobs1 deadheads"));
expect(totals).toHaveTextContent(`${net.slice(1)} net`);
});
it("is reachable from the Bridge", async () => {
renderApp(app(), "/");
const card = await screen.findByLabelText("Logbook");
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

View File

@ -131,8 +131,8 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs
| `vite.config.ts` (PWA) | `vite-plugin-pwa` (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, `map-gen` art and icons (`public/icons`, from `scripts/icons.mjs`) | | `vite.config.ts` (PWA) | `vite-plugin-pwa` (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, `map-gen` art and icons (`public/icons`, from `scripts/icons.mjs`) |
| `e2e/*.e2e.ts` | Playwright tests against `vite preview` of two builds made by `build:e2e`: the test build in `dist` (developer tools, `window.deadhead` hook; engine commands run through the hook) and the production build in `dist-production` (Step 14.11: loads only its own files) | | `e2e/*.e2e.ts` | Playwright tests against `vite preview` of two builds made by `build:e2e`: the test build in `dist` (developer tools, `window.deadhead` hook; engine commands run through the hook) and the production build in `dist-production` (Step 14.11: loads only its own files) |
| `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera, notifications on/off (CR-014); local storage, every access wrapped | | `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera, notifications on/off (CR-014); local storage, every access wrapped |
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen; `Logbook` (`/logbook`, not a tab: from "While you were away" and the Bridge; pilot and range filters in the URL, CR-025) |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog (the highlights pop-up built with `buildAwaySummary` from `getLogbook`, CR-025), `PilotLogbook` (`pilotLogbook(state, entries)`: per pilot, newest first, with totals; the Logbook screen's body), `Countdown` |
| `src/components/Fuel.tsx` | Fuel UI (CR-015): `FuelGauge`, the `ShipFuel` sheet (refuel, policy, emergency fuel), `FuelPrice` (cheapest marked), `refuelForLeg` ("Refuel here first") | | `src/components/Fuel.tsx` | Fuel UI (CR-015): `FuelGauge`, the `ShipFuel` sheet (refuel, policy, emergency fuel), `FuelPrice` (cheapest marked), `refuelForLeg` ("Refuel here first") |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) | | `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |

View File

@ -538,5 +538,5 @@ Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "C
- [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10). - [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10).
- [x] **Step 25.2 — Sim: summary builder.** `buildAwaySummary` (`packages/sim/src/away.ts`, pure) takes the absence (`from`, `to`), the logbook entries, every pilot with where they are now, and the problems found from the state, and returns the duration, totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in priority order (discovery, level-up, goal ready, scripted flavor, biggest job, problem) with the overflow count, at most 5 pilot lines by net with the rest summed and the idle count, and the direct list for 3 events or fewer. `flightNet` is payout − pilot share − fuel used − loan repayment. The sim's `LogbookEntry` and the save's `LogEntry` are checked to be the same type. Wording in content (`content.awayWording`, `{name}` placeholders). Tests: `packages/sim/test/away.test.ts` (50 pilots: 5 lines and an overflow line equal to the other 45; priority order and the cap with the right overflow; totals, with fuel bought not counted; the direct list for 2 events and not for 4; only the absence); `packages/engine/test/logbook.test.ts` (an entry's net equals the delivery summary's credits less its fuel); `packages/content/test/onboarding.test.ts` (the wording). - [x] **Step 25.2 — Sim: summary builder.** `buildAwaySummary` (`packages/sim/src/away.ts`, pure) takes the absence (`from`, `to`), the logbook entries, every pilot with where they are now, and the problems found from the state, and returns the duration, totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in priority order (discovery, level-up, goal ready, scripted flavor, biggest job, problem) with the overflow count, at most 5 pilot lines by net with the rest summed and the idle count, and the direct list for 3 events or fewer. `flightNet` is payout − pilot share − fuel used − loan repayment. The sim's `LogbookEntry` and the save's `LogEntry` are checked to be the same type. Wording in content (`content.awayWording`, `{name}` placeholders). Tests: `packages/sim/test/away.test.ts` (50 pilots: 5 lines and an overflow line equal to the other 45; priority order and the cap with the right overflow; totals, with fuel bought not counted; the direct list for 2 events and not for 4; only the absence); `packages/engine/test/logbook.test.ts` (an entry's net equals the delivery summary's credits less its fuel); `packages/content/test/onboarding.test.ts` (the wording).
- [x] **Step 25.3 — Client: highlights pop-up.** New `getLogbook` command: entries, the absence (`awaySince` to now), the last closed one, and problems from the state (`awayProblems` in `packages/engine/src/game/away.ts`: a ship that can't reach anywhere on the fuel aboard, one that needs emergency fuel, a Manual ship whose stored board has no job it could take, credits below the floor with Independent pilots). The pop-up (`apps/web/src/components/AwaySummary.tsx`) still opens on unseen deliveries and finished upgrades, and builds its contents with `buildAwaySummary`: "While you were away · 9 h 40 m", the sleep banner, a totals row, up to 3 highlights and "+N more in the Logbook", up to 5 pilot lines ("Rosa · 6 jobs · +12,300 cr · Resting at Ceres", a Level up badge), "+N more pilots" and the idle line; a short absence lists its events instead ("Dusty Mule delivered Consumer Goods to Moon, +410 cr"). View logbook and Done both close it; View logbook goes to `/logbook?range=absence` (Step 25.4). The builder now drops what a short absence lists (discoveries, level-ups, the biggest job) before capping, and shows each pilot's highest level-up only. Tests: `apps/web/test/away-summary.test.tsx` (the title; the direct list; 50 pilots: totals, 3 highlights and the overflow, 5 lines plus the rest and the idle line; "+N more" only when needed; Done); `packages/engine/test/logbook.test.ts` (`getLogbook`'s windows; each problem); new e2e `apps/web/e2e/25-3-away-fits.e2e.ts` (8 Independent pilots at 375×667: the sheet doesn't scroll and Done is on screen). Tests about per-delivery detail now check the net in the pop-up (pilot share, the lender's cut) or wait for the Logbook screen (rest, the pilot logbook). Screenshots: `docs/screenshots/25-3-away-{phone,desktop}.png`. - [x] **Step 25.3 — Client: highlights pop-up.** New `getLogbook` command: entries, the absence (`awaySince` to now), the last closed one, and problems from the state (`awayProblems` in `packages/engine/src/game/away.ts`: a ship that can't reach anywhere on the fuel aboard, one that needs emergency fuel, a Manual ship whose stored board has no job it could take, credits below the floor with Independent pilots). The pop-up (`apps/web/src/components/AwaySummary.tsx`) still opens on unseen deliveries and finished upgrades, and builds its contents with `buildAwaySummary`: "While you were away · 9 h 40 m", the sleep banner, a totals row, up to 3 highlights and "+N more in the Logbook", up to 5 pilot lines ("Rosa · 6 jobs · +12,300 cr · Resting at Ceres", a Level up badge), "+N more pilots" and the idle line; a short absence lists its events instead ("Dusty Mule delivered Consumer Goods to Moon, +410 cr"). View logbook and Done both close it; View logbook goes to `/logbook?range=absence` (Step 25.4). The builder now drops what a short absence lists (discoveries, level-ups, the biggest job) before capping, and shows each pilot's highest level-up only. Tests: `apps/web/test/away-summary.test.tsx` (the title; the direct list; 50 pilots: totals, 3 highlights and the overflow, 5 lines plus the rest and the idle line; "+N more" only when needed; Done); `packages/engine/test/logbook.test.ts` (`getLogbook`'s windows; each problem); new e2e `apps/web/e2e/25-3-away-fits.e2e.ts` (8 Independent pilots at 375×667: the sheet doesn't scroll and Done is on screen). Tests about per-delivery detail now check the net in the pop-up (pilot share, the lender's cut) or wait for the Logbook screen (rest, the pilot logbook). Screenshots: `docs/screenshots/25-3-away-{phone,desktop}.png`.
- [ ] Step 25.4 — Client: Logbook screen - [x] **Step 25.4 — Client: Logbook screen.** `/logbook` (`apps/web/src/screens/Logbook.tsx`), reached from View logbook and a Logbook card on the Bridge: the last 7 days from `getLogbook`, filtered by pilot (All pilots or one) and time (This absence, Last 24 h, Last 7 days), both kept in the URL. "This absence" is the one the summary shows: the open one while it's showing, else the last one closed. `pilotLogbook(state, entries)` groups entries by pilot in roster order (then the company's own, such as goals), newest first, with totals (jobs, deadheads, paid, pilot shares, fuel, to the lender, net, XP). Each line has its time (and date on earlier days) and every figure: a job's good, route, payout, share, fuel, lender and XP; deadheads; refuels with who bought them; rests; finished upgrades; discoveries; level-ups; goals. 50 lines per pilot, then Show more. Tests: `apps/web/test/logbook-screen.test.tsx` (every kind of line with its detail, including the pilot share, the lender's cut and rest that moved out of the pop-up; the pilot and range filters; View logbook opens This absence with the pop-up's net and deliveries; the Bridge link); `apps/web/test/board-logbook.test.tsx` (the logbook from entries, its totals). Screenshots: `docs/screenshots/25-4-logbook-{phone,desktop}.png`.
- [ ] Step 25.5 — Regression - [ ] Step 25.5 — Regression