diff --git a/DECISIONS.md b/DECISIONS.md
index 7e664d4..43386c6 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -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".
- **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.
+
+## 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).
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index bff24df..a3afa30 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -17,6 +17,7 @@ import { HQs } from "./screens/HQs";
import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots";
+import { Logbook } from "./screens/Logbook";
import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences";
import { StartScreen } from "./screens/StartScreen";
@@ -113,6 +114,8 @@ export function App() {
))}
{/* Settings opens from the header gear, not a tab (CR-011). */}
} />
+ {/* The Logbook opens from "While you were away" and the Bridge (CR-025). */}
+ } />
diff --git a/apps/web/src/components/PilotLogbook.tsx b/apps/web/src/components/PilotLogbook.tsx
index adb0f0f..98ca398 100644
--- a/apps/web/src/components/PilotLogbook.tsx
+++ b/apps/web/src/components/PilotLogbook.tsx
@@ -1,40 +1,23 @@
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 { useClock } from "../clock/clock";
import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
-import { Icon } from "../ui";
-import { independentStatus } from "./IndependentPilots";
-
-/** 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 };
+import { Button, Icon } from "../ui";
+import { pilotNow } from "./AwaySummary";
+import { formatSleepTime } from "./FleetSleep";
+/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
export interface PilotLog {
- pilotId: string;
+ /** Null for the company: goals, and anything without a pilot. */
+ pilotId: string | null;
name: string;
+ /** Newest first. */
entries: LogEntry[];
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;
}
@@ -45,139 +28,222 @@ export interface Logbook {
payout: number;
pilotShare: number;
fuelCost: number;
+ loanRepaid: number;
+ /** Payout less pilot shares, fuel used and the loan (decision 0.5.142). */
+ net: number;
xp: number;
};
pilots: PilotLog[];
}
/**
- * The pilot logbook (CR-021 §2): Independent pilots' own trips since the
- * player last looked, by pilot in roster order, oldest first, with company
- * totals that are the sums of the entries.
+ * The logbook for some entries (decision 0.5.141): by pilot in roster order,
+ * then the company's own, newest first, with totals that are the sums of the
+ * entries. Pilots with no entries are left out.
*/
-export function pilotLogbook(state: PlayerState, summaries: readonly ResolutionSummary[]): Logbook {
- const own = summaries.filter((s) => s.independent).sort((a, b) => a.resolvedAt - b.resolvedAt);
+export function pilotLogbook(state: PlayerState, entries: readonly LogEntry[]): Logbook {
+ 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 xpOf = (list: LogEntry[]) =>
+ list.reduce((sum, e) => sum + (e.kind === "flight" ? e.xp : 0), 0);
for (const pilot of state.pilots) {
- const trips = own.filter((s) => s.pilotId === pilot.id);
- if (trips.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 });
- }
- }
+ const own = newest.filter((e) => e.pilotId === pilot.id);
+ if (own.length === 0) continue;
pilots.push({
pilotId: pilot.id,
name: pilot.name,
- entries,
- xp: trips.reduce((sum, s) => sum + s.pilotXp, 0),
- now:
- independentStatus(state, pilot) ??
- (pilot.locationId ? `At ${locationName(pilot.locationId)}` : "In flight"),
+ entries: own,
+ xp: xpOf(own),
+ now: pilotNow(state, pilot),
});
}
- const all = pilots.flatMap((p) => p.entries);
- const jobs = all.filter((e) => e.kind === "job");
- const deadheads = all.filter((e) => e.kind === "deadhead");
+ const company = newest.filter((e) => e.pilotId === null || !known.has(e.pilotId));
+ if (company.length > 0) {
+ pilots.push({ pilotId: null, name: "Company", entries: company, xp: 0, now: "" });
+ }
+ const flights = entries.filter(
+ (e): e is Extract => e.kind === "flight",
+ );
+ const jobs = flights.filter((f) => !f.deadhead);
return {
totals: {
jobs: jobs.length,
- deadheads: deadheads.length,
- payout: jobs.reduce((sum, e) => sum + e.payout, 0),
- pilotShare: jobs.reduce((sum, e) => sum + e.pilotShare, 0),
- fuelCost: [...jobs, ...deadheads].reduce((sum, e) => sum + e.fuelCost, 0),
- xp: pilots.reduce((sum, p) => sum + p.xp, 0),
+ deadheads: flights.length - jobs.length,
+ payout: jobs.reduce((sum, f) => sum + f.payout, 0),
+ pilotShare: flights.reduce((sum, f) => sum + f.pilotPay, 0),
+ fuelCost: flights.reduce((sum, f) => sum + f.fuelCost, 0),
+ loanRepaid: flights.reduce((sum, f) => sum + f.loanRepayment, 0),
+ net: flights.reduce((sum, f) => sum + flightNet(f), 0),
+ xp: xpOf(flights),
},
pilots,
};
}
-/** The pilot logbook on the "while you were away" sheet. */
-export function PilotLogbook({ logbook }: { logbook: Logbook }) {
- if (logbook.pilots.length === 0) return null;
+const REFUEL_SOURCE = {
+ "top-up": "Auto top-up",
+ 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) => (
+
+ {locationName(from)} → {locationName(to)}
+
+ );
+ const fuel = (cost: number) => (
+ fuel {formatCredits(cost)}
+ );
+ switch (entry.kind) {
+ case "flight":
+ return entry.deadhead ? (
+ <>
+
+ Deadhead
+ {route(entry.originId, entry.destinationId)}
+ {fuel(entry.fuelCost)}
+ >
+ ) : (
+ <>
+
+ {(entry.goodId && content.good(entry.goodId)?.name) || "Cargo"}
+
+ {route(entry.originId, entry.destinationId)}
+ {formatCredits(entry.payout)}
+ {entry.pilotPay > 0 && (
+ share {formatCredits(entry.pilotPay)}
+ )}
+ {fuel(entry.fuelCost)}
+ {entry.loanRepayment > 0 && (
+ lender {formatCredits(entry.loanRepayment)}
+ )}
+ {entry.xp > 0 && +{entry.xp} XP}
+ >
+ );
+ case "refuel":
+ return (
+
+ {Math.round(entry.boughtFu)} fu at {locationName(entry.locationId)},{" "}
+ {formatCredits(entry.cost)} ({REFUEL_SOURCE[entry.source]})
+
+ );
+ case "rest":
+ return (
+
+
+ Rested until {formatLocalTime(entry.until)}
+
+ );
+ case "upgrade":
+ return (
+
+ {shipName(state, entry.shipId)}:{" "}
+ {content.upgrade(entry.upgradeId)?.name ?? entry.upgradeId} level {entry.level} finished
+
+ );
+ case "discovery":
+ return Discovered {locationName(entry.locationId)};
+ case "level-up":
+ return (
+
+ Reached level {entry.level} · {entry.skillPoints}{" "}
+ {entry.skillPoints === 1 ? "skill point" : "skill points"}
+
+ );
+ case "goal-ready":
+ return (
+
+ Goal ready: {content.onboardingGoal(entry.goalId)?.title ?? entry.goalId}
+
+ );
+ }
+}
+
+/** 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 (
+
+
+ {log.name}
+ {log.xp > 0 && +{log.xp} XP}
+
+ {log.now && (
+
+ {log.now}
+
+ )}
+
+ {log.entries.slice(0, shown).map((e) => (
+
+
+
+
+ ))}
+
+ {log.entries.length > shown && (
+
+ )}
+
+ );
+}
+
+/** The logbook's figures and its pilots' sections (decision 0.5.141). */
+export function PilotLogbook({ logbook, state }: { logbook: Logbook; state: PlayerState }) {
const { totals } = logbook;
return (
-
+
{totals.jobs} jobs{totals.deadheads} deadheads{formatCredits(totals.payout)} paid{formatCredits(totals.pilotShare)} pilot shares{formatCredits(totals.fuelCost)} fuel
+ {formatCredits(totals.loanRepaid)} to the lender
+
+ {formatCredits(totals.net)} net
+ +{totals.xp} XP