diff --git a/DECISIONS.md b/DECISIONS.md
index e5fa39b..3a03d74 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -794,3 +794,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot.
- **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes.
- **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away.
+
+## Step 10D.6
+
+- **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts.
+- **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows.
+- **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours.
diff --git a/apps/web/src/screens/Pilots.tsx b/apps/web/src/screens/Pilots.tsx
index 65d3fe3..d16fd6b 100644
--- a/apps/web/src/screens/Pilots.tsx
+++ b/apps/web/src/screens/Pilots.tsx
@@ -1,7 +1,15 @@
import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema";
import { useState } from "react";
-import { formatCredits, locationName, shipModelName } from "../format";
+import { MS_PER_HOUR } from "@deadhead/sim";
+import { Countdown } from "../components/Countdown";
+import {
+ formatCredits,
+ formatDuration,
+ formatGameHours,
+ locationName,
+ shipModelName,
+} from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** XP needed for the pilot's current and next level, or null at max level. */
@@ -13,7 +21,7 @@ export function levelProgress(xp: number, level: number) {
return { xp: xp - previous, needed: next - previous };
}
-/** Pilots: levels, skills, assignment; hire, assign and spend skill points (Step 9.7). */
+/** Pilots: levels, skills, assignment, duty and rest; hire, assign, rest and spend skill points (Steps 9.7, 10D.6). */
export function Pilots() {
const state = useLoadedPlayer();
return (
@@ -149,6 +157,8 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
)}
+
+
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
)}
@@ -156,6 +166,95 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
);
}
+/** Real time a rest lasts: at least one contract board period (decision 0.5.46). */
+export function restRealMs(): number {
+ const { restGameHours, timeCompression } = content.config;
+ return (restGameHours * MS_PER_HOUR) / timeCompression;
+}
+
+/** Duty used of the limit, or the rest countdown; and "Rest now" with a confirmation (CR-004). */
+function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
+ const rest = useGameAction("restPilot");
+ const [confirming, setConfirming] = useState(false);
+
+ if (pilot.resting && pilot.restEndsAt !== null) {
+ return (
+
+ {pilot.name} rests for {formatDuration(restRealMs())} and can't fly until then.
+ Rest always lasts at least one board refresh, so they wake to new contracts.
+
+
+
+
+
+
+ )}
+ {rest.isError && (
+
+ {errorMessage(rest.error)}
+
+ )}
+
+ );
+}
+
/** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */
function AssignShip({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer();
diff --git a/apps/web/test/pilots-duty.test.tsx b/apps/web/test/pilots-duty.test.tsx
new file mode 100644
index 0000000..53138e4
--- /dev/null
+++ b/apps/web/test/pilots-duty.test.tsx
@@ -0,0 +1,92 @@
+import type { Pilot, PlayerState } 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 { fakeApi, renderApp, samplePlayerState } from "./render";
+
+const NOW = Date.parse("2100-01-01T00:00:00Z");
+
+const pilot = (overrides: Partial): Pilot => ({
+ id: "p",
+ name: "Pilot",
+ isAvatar: false,
+ xp: 0,
+ level: 0,
+ skills: {},
+ skillPointsAvailable: 0,
+ assignedShipId: null,
+ status: "idle",
+ locationId: "earth",
+ dutyHours: 0,
+ dutyLimit: 12,
+ resting: false,
+ restEndsAt: null,
+ ...overrides,
+});
+
+const state = (): PlayerState =>
+ samplePlayerState({
+ tutorial: null,
+ bays: [{ id: "b1", locationId: "earth" }],
+ pilots: [
+ pilot({ id: "p-rae", name: "Rae Okafor", dutyHours: 9, dutyLimit: 14 }),
+ pilot({
+ id: "p-jun",
+ name: "Jun Sato",
+ resting: true,
+ restEndsAt: NOW + 25 * 60_000,
+ }),
+ ],
+ });
+
+const card = async (name: string) => (await screen.findByText(name)).closest("li")!;
+
+describe("pilot duty on the Pilots screen (Step 10D.6)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
+ vi.setSystemTime(NOW);
+ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("shows a duty bar matching the pilot's hours and limit", async () => {
+ renderApp(fakeApi({ getPlayerState: state }), "/pilots");
+ const rae = await card("Rae Okafor");
+ const bar = within(rae).getByRole("progressbar", { name: "Rae Okafor duty" });
+ expect(bar).toHaveAttribute("aria-valuenow", "9");
+ expect(bar).toHaveAttribute("aria-valuemax", "14");
+ expect(rae).toHaveTextContent("9.0 h of 14 h");
+ });
+
+ it("shows a resting pilot's countdown and no Rest now action", async () => {
+ renderApp(fakeApi({ getPlayerState: state }), "/pilots");
+ const jun = await card("Jun Sato");
+ expect(jun).toHaveTextContent("Resting · wakes in 25m 00s");
+ expect(within(jun).queryByRole("button", { name: "Rest now" })).not.toBeInTheDocument();
+ expect(within(jun).queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+
+ it("rests a pilot after confirming that rest lasts at least one board refresh", async () => {
+ const api = fakeApi({
+ getPlayerState: state,
+ restPilot: ({ pilotId }) => ({
+ pilot: pilot({
+ id: pilotId,
+ name: "Rae Okafor",
+ resting: true,
+ restEndsAt: NOW + 3_600_000,
+ }),
+ }),
+ });
+ renderApp(api, "/pilots");
+ const rae = await card("Rae Okafor");
+ await userEvent.click(within(rae).getByRole("button", { name: "Rest now" }));
+ const confirm = within(rae).getByRole("group", { name: "Rest Rae Okafor?" });
+ expect(confirm).toHaveTextContent("at least one board refresh");
+ expect(api.call).not.toHaveBeenCalledWith("restPilot", expect.anything());
+
+ await userEvent.click(within(confirm).getByRole("button", { name: "Rest Rae Okafor" }));
+ await waitFor(() => expect(api.call).toHaveBeenCalledWith("restPilot", { pilotId: "p-rae" }));
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 236a9f2..21a9547 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -169,7 +169,7 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`.
- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated.
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
-- [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
+- [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`.
- [ ] Step 10D.7 — Client: duty in the dispatch flow
- [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
- [ ] Step 10D.9 — Client: rest in summaries and on HQ