Compare commits

..

2 Commits

Author SHA1 Message Date
fireshaper d6f782c444 Step 28.4: client: pop-up only after being away
CI / check (push) Has been cancelled Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:31:57 -04:00
fireshaper 43859765eb Step 28.3: client: toasts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:33:11 -04:00
38 changed files with 1185 additions and 79 deletions

View File

@ -2058,3 +2058,19 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Presence times come from the game clock**, not `Date.now()`: logbook entries are stamped with it, and test builds move it forward with the developer tools.
- **A cold start returns from the save's `lastTimeSeen`**: while the game is open the resolution loop runs a command every few seconds, so that's when the game was last on screen (or running in a background tab).
- **On iOS and Android, `appStateChange` decides**; the web build uses the Page Visibility API. A page opened in a background tab is away until it's shown.
## Step 28.3
- **Toasts sit below the top bar** on both layouts (top-right on desktop, centered on phones, under the safe area), so the credits and clock stay visible (as with sheets).
- **"+N more" counts the toasts beyond the 3 newest**: with four, three show and "+1 more" sits under them; tapping it opens the Logbook and drops the hidden ones. When a shown toast closes, the next hidden one moves up.
- **Batching is by time on screen**: an Independent pilot's routine delivery (not a deadhead, tutorial run or scripted job) joins a batch toast shown in the last 10 s; one alone reads like any delivery. The player's own deliveries, discoveries, level-ups and problems are never batched.
- **Entries with no toast are marked seen too** (deadheads, refuelling, rest, automatic bookings): the player was here when they happened. A repair or upgrade a booked service finished is toasted once, as "service finished".
- **Only entries stamped after the player came back are toasted** (`hereSince`); earlier unseen ones belong to the absence (Step 28.4). "Can't reach" isn't an entry, so it's toasted when it first appears among the problems while the game is open.
## Step 28.4
- **The pop-up covers every unseen entry from before the return**, not only those after the time the player left: what happened while the player was here is always marked seen (toasted, or silently for deadheads and the like), so anything still unseen happened while away. That also covers a game closed in a hidden tab.
- **A cold start comes back from when the player went away**, remembered on the device: a hidden tab keeps the game running and saving, so the save's last time seen can be hours after the player left. Without that memory (a new device, storage blocked), the save's last time seen is used.
- **Only entries worth a toast open the pop-up**: a return to only refuelling, rest or automatic bookings shows nothing (and marks them seen).
- **Delivery summaries are still acknowledged**, now by whichever showed the news (the feed, the toasts after a short absence, or the pop-up), because the deal, the skill-point prompt and radio calls wait for them; a new `useAwayOpen` flag also holds those back while the pop-up decides or shows.
- **The Logbook's "This absence"** is the absence the player last came back from in this session; before any, the save's own.

View File

@ -4,7 +4,8 @@ import {
advanceTo,
dismissLogbook,
dispatchFromEarthBoard,
logbook,
expectNews,
goAway,
playToDeal,
state,
} from "./support";
@ -27,10 +28,11 @@ test.describe("end to end (Phase 12)", () => {
// One contract, dispatched in the UI and resolved.
const dispatchId = await dispatchFromEarthBoard(page);
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
await goAway(page);
await advanceTo(page, flight.arrivalAt);
await page.reload();
// The net, after the lender's cut (CR-025); the cut itself is in the Logbook.
await expect(logbook(page)).toContainText(/delivered .+ to Moon, \+[\d,]+ cr/);
await expectNews(page, /delivered .+ to Moon, \+[\d,]+ cr/);
await dismissLogbook(page);
const after = await state(page);

View File

@ -3,12 +3,13 @@ import { content } from "@deadhead/content";
import type { Contract, Dispatch } from "@deadhead/schema";
import {
acknowledgeAll,
advance,
addCredits,
advance,
advanceTo,
board,
dismissLogbook,
logbook,
expectNews,
goAway,
playToDeal,
run,
state,
@ -58,9 +59,10 @@ test.describe("end to end (Phase 12)", () => {
expect(offer, "a first contact to Mars on an Earth board").toBeDefined();
const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id);
expect(flight, "the Clipper and veteran can take it").not.toBeNull();
await goAway(page);
await advanceTo(page, flight!.arrivalAt);
await page.goto("/");
await expect(logbook(page)).toContainText("opened a new market: Mars");
await expectNews(page, "opened a new market: Mars");
await dismissLogbook(page);
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).

View File

@ -1,5 +1,12 @@
import { expect, test } from "@playwright/test";
import { advanceTo, dismissLogbook, dispatchFromEarthBoard, playToDeal, state } from "./support";
import {
advanceTo,
dismissLogbook,
dispatchFromEarthBoard,
goAway,
playToDeal,
state,
} from "./support";
test.describe("the game runs on the device (CR-014 Step 14.7)", () => {
test("plays the opening with every network request outside the app's own files blocked", async ({
@ -22,6 +29,7 @@ test.describe("the game runs on the device (CR-014 Step 14.7)", () => {
expect(owner.ships.map((s) => s.modelId)).toEqual(["courier", "clipper"]);
const dispatchId = await dispatchFromEarthBoard(page);
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
await goAway(page);
await advanceTo(page, flight.arrivalAt);
await page.reload();
await dismissLogbook(page);

View File

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import type { PlayerState } from "@deadhead/schema";
import { addCredits, advance, logbook, playToDeal, run, state } from "./support";
import { addCredits, advance, goAway, logbook, playToDeal, run, state } from "./support";
const HOUR = 3_600_000;
const PILOTS = 7;
@ -28,6 +28,8 @@ test.describe("the highlights pop-up (CR-025 Step 25.3)", () => {
(p: PlayerState["pilots"][number]) => p.isVeteran,
)!;
await run(page, "setPilotMode", { pilotId: rosa.id, mode: "independent" });
// Away for the four hours (CR-028), back with a fresh load.
await goAway(page);
for (let h = 0; h < 4; h++) await advance(page, HOUR);
await page.goto("/");

View File

@ -57,16 +57,43 @@ export const logbook = (page: Page) =>
});
/** Waits for the logbook when the server has unseen summaries, and dismisses it. */
/**
* Closes "While you were away" if coming back opened it (CR-028: after a
* short absence the news shows as toasts instead, and closes the summaries
* itself). Waits until no delivery summary is left.
*/
export async function dismissLogbook(page: Page) {
if ((await state(page)).summaries.length === 0) return;
const sheet = logbook(page);
await expect(sheet).toBeVisible();
await sheet.getByRole("button").last().click();
await expect(sheet).toBeHidden();
await expect
.poll(async () => (await sheet.isVisible()) || (await state(page)).summaries.length === 0, {
timeout: 15_000,
})
.toBe(true);
if (!(await sheet.isVisible())) {
// Nothing to close yet: give a summary that is still reading the logbook a moment.
await sheet.waitFor({ state: "visible", timeout: 1_500 }).catch(() => {});
}
if (await sheet.isVisible()) {
await sheet.getByRole("button").last().click();
await expect(sheet).toBeHidden();
}
// The acknowledgement is saved in the page; don't navigate away before it lands.
await expect.poll(async () => (await state(page)).summaries.length).toBe(0);
}
/** The tab hidden (CR-028): the player is away; the game keeps running. */
export async function goAway(page: Page) {
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
document.dispatchEvent(new Event("visibilitychange"));
});
}
/** News, as a toast (the player was here, or briefly away) or in "While you were away". */
export async function expectNews(page: Page, text: RegExp | string) {
await expect(page.getByText(text).first()).toBeVisible();
}
/** A brand-new company: "Start your company" from the start screen. */
export async function startCompany(page: Page) {
await page.goto("/");
@ -88,6 +115,8 @@ export async function flyTutorial(page: Page) {
await page.getByRole("button", { name: "Fly it" }).first().click();
await expect.poll(async () => (await state(page)).activeDispatches.length).toBe(1);
const [flight] = (await state(page)).activeDispatches;
// Away while it flies (CR-028), back with a reload.
await goAway(page);
await advanceTo(page, flight!.arrivalAt);
await page.reload();
await dismissLogbook(page);

View File

@ -3,6 +3,8 @@ import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
import { AwaySummary } from "./components/AwaySummary";
import { ToastFeed } from "./components/ToastFeed";
import { Toasts } from "./components/Toasts";
import { DealModal } from "./components/DealModal";
import { WelcomeGate } from "./components/Welcome";
import { SkillPointPrompt } from "./components/SkillPointPrompt";
@ -121,6 +123,9 @@ export function App() {
</Route>
</Routes>
<AwaySummary />
{/* What happens while the player watches (CR-028). */}
<ToastFeed />
<Toasts />
<RadioCallSheet />
<DealModal />
<WelcomeGate />

View File

@ -8,9 +8,13 @@ import {
type AwaySummary as Summary,
} from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { clientConfig } from "../client-config";
import { usePresence, type Absence } from "../platform/presence";
import { useToasts } from "../state/toasts";
import { useClock } from "../clock/clock";
import { fill, formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions";
@ -18,6 +22,7 @@ import { Button, Icon, Sheet } from "../ui";
import { eventResultText } from "./EventResults";
import { SleepBanner } from "./FleetSleep";
import { serviceItems } from "./Service";
import { entryToasts } from "./ToastFeed";
const words = content.awayWording;
@ -192,45 +197,97 @@ export function useAwaySummary(
});
}
/** Whether "While you were away" is deciding or open: other sheets wait for it. */
export const useAwayOpen = create<{ open: boolean }>(() => ({ open: false }));
/** An absence's unseen news: the entries the summary covers, and what needs the player now. */
interface News {
absence: Absence;
entries: LogEntry[];
problems: AwayProblem[];
}
/**
* "While you were away" (Step 9.2, decision 0.5.140): highlights on one phone
* screen, the detail in the Logbook. Opens on unseen deliveries and finished
* upgrades, as before.
* "While you were away" (Step 9.2, decisions 0.5.140, 0.5.162): on coming
* back (the tab shown again, the app in front, or a fresh launch), the unseen
* entries from that absence. With none worth a toast, nothing shows; away
* under 60 seconds, they show as toasts; otherwise the pop-up, and they're
* marked seen when it closes. Never while the player is here: that's toasts.
*/
export function AwaySummary() {
const state = useLoadedPlayer();
const acknowledge = useGameAction("acknowledgeSummaries");
// Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const api = useApi();
const navigate = useNavigate();
const acknowledge = useGameAction("acknowledgeSummaries");
const push = useToasts((s) => s.push);
const returns = usePresence((p) => p.returns);
const lastReturn = usePresence((p) => p.lastReturn);
const now = useClock((s) => s.now);
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
// Upgrades finished and not yet seen: "out of the shop" (CR-009).
const outOfShop = state.ships.filter(
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
);
const open = summaries.length > 0 || outOfShop.length > 0;
const refresh = [...summaries.map((s) => s.dispatchId), ...outOfShop.map((s) => s.id)].join();
const summary = useAwaySummary(state, refresh, open);
if (!open) return null;
const [news, setNews] = useState<News | null>(null);
const handled = useRef(0);
// The latest state and time, for work that finishes after a refresh.
const latest = useRef({ state, now });
latest.current = { state, now };
const dismiss = () => {
const ids = summaries.map((s) => s.dispatchId);
const shipIds = outOfShop.map((s) => s.id);
setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)]));
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
/** The news is dealt with: its entries seen, and the delivery summaries behind it closed. */
const settle = useCallback(
(entries: readonly LogEntry[]) => {
if (entries.length > 0) {
void api.call("markLogbookSeen", { entryIds: entries.map((e) => e.id) }).catch(() => {});
}
const { state: s, now: t } = latest.current;
const dispatchIds = s.summaries.map((x) => x.dispatchId);
const shipIds = s.ships.filter((x) => x.shop !== null && x.shop.until <= t).map((x) => x.id);
if (dispatchIds.length > 0 || shipIds.length > 0) {
acknowledge.mutate({ dispatchIds, ...(shipIds.length > 0 ? { shipIds } : {}) });
}
useAwayOpen.setState({ open: false });
},
[api, acknowledge],
);
useEffect(() => {
if (!lastReturn || returns === handled.current) return;
handled.current = returns;
useAwayOpen.setState({ open: true });
void api
.call("getLogbook", {})
.then((book) => {
// Everything from while the player was here is seen (toasted), so what's
// unseen from before coming back happened while away.
const entries = book.entries.filter((e) => !e.seen && e.at < lastReturn.to);
const toasts = entryToasts(entries, latest.current.state);
if (toasts.length === 0) return settle(entries);
if (lastReturn.to - lastReturn.from < clientConfig.presence.shortAbsenceMs) {
// A quick look elsewhere: toasts, not a summary.
push(toasts);
return settle(entries);
}
setNews({ absence: lastReturn, entries, problems: book.problems });
})
.catch(() => useAwayOpen.setState({ open: false }));
}, [returns, lastReturn, api, push, settle]);
if (!news) return null;
const summary = buildAwaySummary({
...news.absence,
entries: news.entries,
pilots: state.pilots.map((p) => ({ id: p.id, name: p.name, now: pilotNow(state, p) })),
problems: news.problems,
});
const close = () => {
settle(news.entries);
setNews(null);
};
const title = fill(words.title, { duration: summary ? formatAbsence(summary.durationMs) : "…" });
const highlights = summary?.highlights ?? [];
const title = fill(words.title, { duration: formatAbsence(summary.durationMs) });
const highlights = summary.highlights;
return (
<Sheet titleId="away-title" title={title}>
<div className="space-y-3 text-sm">
<SleepBanner />
{!summary ? (
<p className="text-ink-400">Reading the logbook…</p>
) : summary.direct ? (
{summary.direct ? (
<ul aria-label="What happened" className="space-y-1.5">
{summary.direct.map((entry) => (
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
@ -242,7 +299,7 @@ export function AwaySummary() {
) : (
<Totals summary={summary} />
)}
{summary && highlights.length > 0 && (
{highlights.length > 0 && (
<ul aria-label="Highlights" className="space-y-1.5">
{highlights.map((h, i) => (
<li key={i} className="flex items-start gap-2 text-amber-100">
@ -260,20 +317,20 @@ export function AwaySummary() {
)}
</ul>
)}
{summary && !summary.direct && <PilotLines summary={summary} />}
{!summary.direct && <PilotLines summary={summary} />}
</div>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => {
dismiss();
close();
navigate("/logbook?range=absence");
}}
>
{words.viewLogbook}
</Button>
<Button className="flex-1" onClick={dismiss}>
<Button className="flex-1" onClick={close}>
{words.done}
</Button>
</div>

View File

@ -6,6 +6,7 @@ import { formatCredits } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Sheet } from "../ui";
import { useAwayOpen } from "./AwaySummary";
/**
* The one-time deal (decision 0.5.30): shown when the server says it is
@ -13,6 +14,7 @@ import { Art, Button, Sheet } from "../ui";
* decline option.
*/
export function DealModal() {
const awayOpen = useAwayOpen((s) => s.open);
const state = useLoadedPlayer();
const api = useApi();
const queryClient = useQueryClient();
@ -25,7 +27,7 @@ export function DealModal() {
},
});
if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
if (!state.tutorial?.dealAvailable || state.summaries.length > 0 || awayOpen) return null;
const { dealLoanAmount } = content;
const { loanRepaymentShare } = content.config;

View File

@ -9,7 +9,7 @@ import { Button, Icon } from "../ui";
import { pilotNow } from "./AwaySummary";
import { eventResultText } from "./EventResults";
import { formatSleepTime } from "./FleetSleep";
import { serviceItems } from "./Service";
import { serviceEntryText } from "./Service";
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
export interface PilotLog {
@ -172,22 +172,12 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
{formatCredits(entry.cost)}
</span>
);
case "service": {
const items = serviceItems({ ...entry, upgradeLevel: null });
const where = locationName(entry.hqId);
const ship = shipName(state, entry.shipId);
case "service":
return (
<span className={entry.phase === "waiting-funds" ? "text-amber-200" : "text-cyan-100"}>
{entry.phase === "booked"
? `${ship}: ${items} booked automatically at ${where}`
: entry.phase === "started"
? `${ship}: service started at ${where} (${items}), ${formatCredits(entry.cost ?? 0)}`
: entry.phase === "finished"
? `${ship}: service finished at ${where} (${items})`
: `${ship}: waiting for funds for ${items} at ${where}`}
{serviceEntryText(entry, shipName(state, entry.shipId))}
</span>
);
}
case "goal-ready":
return (
<span className="text-amber-200">

View File

@ -7,6 +7,7 @@ import { fill, formatClock, formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Panel, Sheet } from "../ui";
import { Countdown } from "./Countdown";
import { useAwayOpen } from "./AwaySummary";
/** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */
export const useRadioLater = create<{
@ -113,6 +114,8 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
*/
export function RadioCallSheet() {
const state = useLoadedPlayer();
// "While you were away" deciding or open (CR-028).
const away = useAwayOpen((s) => s.open);
const later = useRadioLater((s) => s.later);
const putOff = useRadioLater((s) => s.putOff);
const now = useClock((s) => s.now);
@ -120,6 +123,7 @@ export function RadioCallSheet() {
const call = open.find((c) => !later.has(c.dispatchId));
// After "While you were away" is closed, as the deal waits (one sheet at a time).
const awayOpen =
away ||
state.summaries.length > 0 ||
state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now);
if (!call || awayOpen) return null;

View File

@ -1,5 +1,5 @@
import { content } from "@deadhead/content";
import type { Pilot, PlayerState, Ship } from "@deadhead/schema";
import type { LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { nearestReachableHq, nextUpgradeLevel, repairCost, upgradeCost } from "@deadhead/sim";
import { useState } from "react";
import { formatCredits, locationName } from "../format";
@ -20,6 +20,22 @@ export function serviceItems(booking: Pick<Booking, "repair" | "upgradeId" | "up
return items.join(" + ");
}
/** A service logbook line (CR-027 Step 27.6): booked automatically, started, finished, or waiting. */
export function serviceEntryText(entry: Extract<LogEntry, { kind: "service" }>, ship: string) {
const items = serviceItems({ ...entry, upgradeLevel: null });
const where = locationName(entry.hqId);
switch (entry.phase) {
case "booked":
return `${ship}: ${items} booked automatically at ${where}`;
case "started":
return `${ship}: service started at ${where} (${items}), ${formatCredits(entry.cost ?? 0)}`;
case "finished":
return `${ship}: service finished at ${where} (${items})`;
case "waiting-funds":
return `${ship}: waiting for funds for ${items} at ${where}`;
}
}
/** The Fleet card and dispatch sheet tag (CR-027 §4); null when there's nothing to say. */
export function serviceTag(ship: Ship): string | null {
if (ship.booking) {

View File

@ -5,6 +5,7 @@ import { useApi } from "../api/context";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Button, Sheet } from "../ui";
import { useAwayOpen } from "./AwaySummary";
/** The id of the skill point pop-up in dismissed tips (CR-011 one-time tips). */
export const SKILL_POINTS_TIP = "skill-points";
@ -27,6 +28,7 @@ function listNames(names: string[]): string {
*/
export function SkillPointPrompt() {
const state = useLoadedPlayer();
const awayOpen = useAwayOpen((s) => s.open);
const api = useApi();
const navigate = useNavigate();
const dismissed = usePreferences((p) => p.dismissedTips.includes(SKILL_POINTS_TIP));
@ -48,7 +50,7 @@ export function SkillPointPrompt() {
const ready = state.pilots.filter((p) => p.skillPointsAvailable > 0);
if (!owner || !promptAnswered || dismissed || ready.length === 0) return null;
// Let delivery summaries go first.
if (state.summaries.length > 0) return null;
if (state.summaries.length > 0 || awayOpen) return null;
const names = ready.map(callName);
const subject =

View File

@ -0,0 +1,192 @@
import { content } from "@deadhead/content";
import type { LogEntry, PlayerState } from "@deadhead/schema";
import { flightNet, type AwayProblem } from "@deadhead/sim";
import { useEffect, useRef } from "react";
import { useApi } from "../api/context";
import { usePresence } from "../platform/presence";
import { useClock } from "../clock/clock";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { useToasts, type NewToast } from "../state/toasts";
import { directText, highlightText } from "./AwaySummary";
import { eventResultText } from "./EventResults";
import { fleetQuiet } from "./FleetSleep";
import { serviceEntryText } from "./Service";
const shipName = (state: PlayerState, id: string | null) =>
state.ships.find((s) => s.id === id)?.name ?? "A ship";
/** An Independent pilot's routine delivery: these share a toast (decision 0.5.161). */
function routineDelivery(entry: LogEntry): entry is Extract<LogEntry, { kind: "flight" }> {
return (
entry.kind === "flight" &&
entry.independent &&
!entry.deadhead &&
!entry.tutorial &&
entry.scriptedId === null
);
}
/**
* The toasts for new logbook entries (decision 0.5.161): deliveries,
* discoveries, level-ups, goals ready, event outcomes, service started and
* finished, and waiting for funds. Deadheads, refuelling, rest and automatic
* bookings get none. A repair or upgrade a booked service finished is said
* once, by the service.
*/
export function entryToasts(entries: readonly LogEntry[], state: PlayerState): NewToast[] {
const serviced = new Set(
entries.flatMap((e) =>
e.kind === "service" && e.phase === "finished" ? [`${e.shipId}@${e.at}`] : [],
),
);
return entries.flatMap((entry): NewToast[] => {
switch (entry.kind) {
case "flight":
if (entry.deadhead) return [];
return [
{
text: directText(entry, state),
tone: "good",
to: "/fleet",
...(routineDelivery(entry) ? { batch: { count: 1, net: flightNet(entry) } } : {}),
},
];
case "discovery":
return [{ text: directText(entry, state), tone: "good", to: "/locations" }];
case "level-up":
return [{ text: directText(entry, state), tone: "good", to: "/pilots" }];
case "goal-ready": {
const goal = content.onboardingGoal(entry.goalId)?.title ?? entry.goalId;
return [{ text: `Goal ready: ${goal}`, tone: "good", to: "/" }];
}
case "event":
return [
{ text: eventResultText(entry, state), tone: entry.good ? "good" : "warn", to: "/fleet" },
];
case "service":
if (entry.phase === "booked") return [];
return [
{
text: serviceEntryText(entry, shipName(state, entry.shipId)),
tone: entry.phase === "waiting-funds" ? "warn" : "info",
to: "/fleet",
},
];
case "upgrade":
if (serviced.has(`${entry.shipId}@${entry.at}`)) return [];
return [{ text: directText(entry, state), tone: "info", to: "/fleet" }];
case "repair":
if (serviced.has(`${entry.shipId}@${entry.at}`)) return [];
return [
{
text: `${shipName(state, entry.shipId)}: hull repaired to 100%`,
tone: "info",
to: "/fleet",
},
];
default:
return [];
}
});
}
const problemKey = (p: AwayProblem) =>
p.kind === "below-floor" ? p.kind : `${p.kind}:${p.shipId}:${"hqId" in p ? p.hqId : ""}`;
/**
* Turns what happens while the player is here into toasts (CR-028 Step 28.3):
* after each refresh, the logbook's unseen entries from this stretch here are
* toasted and marked seen; a booked service newly out of reach and the fleet
* going quiet get a toast too. Entries from before (the absence) are left for
* the "while you were away" summary.
*/
export function ToastFeed() {
const state = useLoadedPlayer();
const api = useApi();
const here = usePresence((p) => p.here);
const hereSince = usePresence((p) => p.hereSince);
const push = useToasts((s) => s.push);
const handled = useRef(new Set<string>());
const problems = useRef<Set<string> | null>(null);
const quiet = useRef(fleetQuiet(state));
useEffect(() => {
if (!here || hereSince === null) return;
let cancelled = false;
void api
.call("getLogbook", {})
.then((book) => {
if (cancelled) return;
const fresh = book.entries.filter(
(e) => !e.seen && e.at >= hereSince && !handled.current.has(e.id),
);
for (const e of fresh) handled.current.add(e.id);
const toasts = entryToasts(fresh, state);
// A booked service newly out of reach (CR-027); waiting for funds is an entry.
const now = new Set(book.problems.map(problemKey));
if (problems.current) {
for (const problem of book.problems) {
if (problem.kind !== "service-cant-reach" || problems.current.has(problemKey(problem)))
continue;
toasts.push({
text: highlightText({ kind: "problem", problem }, state),
tone: "warn",
to: "/fleet",
});
}
}
problems.current = now;
if (toasts.length > 0) push(toasts);
if (fresh.length > 0) {
// If this fails they stay unseen, but they happened while the player was here,
// so no away summary will pick them up.
void api.call("markLogbookSeen", { entryIds: fresh.map((e) => e.id) }).catch(() => {});
}
})
// The next refresh tries again.
.catch(() => {});
return () => {
cancelled = true;
};
}, [state, here, hereSince, api, push]);
// Deliveries and finished upgrades from while the player is here were toasted: close
// their summaries, as the pop-up used to, so the sheets waiting on them can show.
const acknowledge = useGameAction("acknowledgeSummaries");
const acked = useRef(new Set<string>());
const clock = useClock((c) => c.now);
useEffect(() => {
if (!here || hereSince === null) return;
const dispatchIds = state.summaries
.filter((s) => s.resolvedAt >= hereSince && !acked.current.has(s.dispatchId))
.map((s) => s.dispatchId);
const shipIds = state.ships
.filter(
(s) =>
s.shop !== null &&
s.shop.until >= hereSince &&
s.shop.until <= clock &&
!acked.current.has(`ship:${s.id}:${s.shop.until}`),
)
.map((s) => s.id);
if (dispatchIds.length === 0 && shipIds.length === 0) return;
for (const id of dispatchIds) acked.current.add(id);
for (const s of state.ships) {
if (shipIds.includes(s.id)) acked.current.add(`ship:${s.id}:${s.shop!.until}`);
}
acknowledge.mutate({ dispatchIds, ...(shipIds.length > 0 ? { shipIds } : {}) });
}, [state, here, hereSince, clock, acknowledge]);
// The fleet going quiet while the game is open: a toast, beside the Bridge's panel (CR-024).
useEffect(() => {
const now = fleetQuiet(state);
if (now && !quiet.current && here) {
push([
{ text: "Your fleet went quiet: pilots are waiting for orders.", tone: "warn", to: "/" },
]);
}
quiet.current = now;
}, [state, here, push]);
return null;
}

View File

@ -0,0 +1,146 @@
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { useNavigate } from "react-router";
import { clientConfig } from "../client-config";
import { reducedMotionOn, usePreferences } from "../state/preferences";
import { useToasts, type Toast } from "../state/toasts";
import { Icon } from "../ui";
const { durationMs, maxVisible, desktopMinWidthPx } = clientConfig.toasts;
/** A sideways drag this far dismisses a toast. */
const SWIPE_PX = 60;
/** The viewport's width, kept current on resize. */
function useViewportWidth(): number {
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
const TONES: Record<Toast["tone"], string> = {
good: "border-emerald-400/40 text-emerald-50",
info: "border-cyan-400/40 text-cyan-50",
warn: "border-amber-400/50 text-amber-50",
};
/**
* One toast: closes after 5 s, paused while hovered or touched; ✕ or a swipe
* closes it early; tapping opens its screen (decision 0.5.161).
*/
function ToastItem({
toast,
motion,
desktop,
}: {
toast: Toast;
motion: boolean;
desktop: boolean;
}) {
const dismiss = useToasts((s) => s.dismiss);
const navigate = useNavigate();
const [paused, setPaused] = useState(false);
const left = useRef<number>(durationMs);
const swipe = useRef<{ x: number; moved: boolean } | null>(null);
useEffect(() => {
if (paused) return;
const started = Date.now();
const timer = window.setTimeout(() => dismiss(toast.id), left.current);
return () => {
window.clearTimeout(timer);
left.current = Math.max(0, left.current - (Date.now() - started));
};
}, [paused, dismiss, toast.id]);
const onPointerDown = (e: PointerEvent) => {
swipe.current = { x: e.clientX, moved: false };
};
const onPointerUp = (e: PointerEvent) => {
const start = swipe.current;
if (start && Math.abs(e.clientX - start.x) >= SWIPE_PX) {
start.moved = true;
dismiss(toast.id);
}
};
const open = () => {
if (swipe.current?.moved) return;
dismiss(toast.id);
void navigate(toast.to);
};
return (
<li
data-toast
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => setPaused(false)}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
className={`pointer-events-auto flex items-start gap-2 rounded-xl border bg-space-900/95 py-2 pr-1.5 pl-3 text-sm shadow-panel backdrop-blur ${TONES[toast.tone]} ${motion ? (desktop ? "animate-slide" : "animate-rise") : ""}`}
>
<button type="button" onClick={open} className="min-w-0 flex-1 py-0.5 text-left">
{toast.text}
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => dismiss(toast.id)}
className="grid size-7 shrink-0 place-items-center rounded-lg text-ink-400 hover:bg-space-700 hover:text-ink-100"
>
<Icon name="close" className="size-4" />
</button>
</li>
);
}
/**
* The toasts on screen (decision 0.5.161): top-right on desktop, top-center
* below the safe area on phones; the newest on top, at most 3, and the rest as
* "+N more", which opens the Logbook. Announced politely; no sliding with
* reduced motion.
*/
export function Toasts() {
const toasts = useToasts((s) => s.toasts);
const dropOverflow = useToasts((s) => s.dropOverflow);
const navigate = useNavigate();
const motion = !usePreferences(reducedMotionOn);
const desktop = useViewportWidth() >= desktopMinWidthPx;
const shown = toasts.slice(0, maxVisible);
const more = toasts.length - shown.length;
return (
<section
aria-label="Notifications"
aria-live="polite"
data-placement={desktop ? "top-right" : "top-center"}
data-motion={motion ? "slide" : "none"}
className={`pointer-events-none fixed z-[60] flex flex-col gap-2 ${
desktop
? "top-[calc(env(safe-area-inset-top)+4rem)] right-4 w-80"
: "inset-x-0 top-[calc(env(safe-area-inset-top)+4rem)] mx-auto w-[min(24rem,calc(100%-2rem))]"
}`}
>
<ul className="flex flex-col gap-2">
{shown.map((toast) => (
<ToastItem key={toast.id} toast={toast} motion={motion} desktop={desktop} />
))}
</ul>
{more > 0 && (
<button
type="button"
onClick={() => {
dropOverflow();
void navigate("/logbook");
}}
className="pointer-events-auto self-end rounded-full border border-space-600 bg-space-900/95 px-3 py-1 font-display text-xs tracking-wide text-ink-200 shadow-panel"
>
+{more} more
</button>
)}
</section>
);
}

View File

@ -16,6 +16,8 @@ export interface Absence {
*/
export interface PresenceState {
here: boolean;
/** Since when the player has been here; null while away. */
hereSince: number | null;
awaySince: number | null;
lastReturn: Absence | null;
returns: number;
@ -23,9 +25,11 @@ export interface PresenceState {
/** The state when the game starts: back from `savedAt` (null for a new game) if it opens in front. */
export function presenceAtStart(here: boolean, now: number, savedAt: number | null): PresenceState {
if (!here) return { here, awaySince: savedAt ?? now, lastReturn: null, returns: 0 };
if (!here)
return { here, hereSince: null, awaySince: savedAt ?? now, lastReturn: null, returns: 0 };
return {
here,
hereSince: now,
awaySince: null,
lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null,
returns: savedAt !== null && savedAt < now ? 1 : 0,
@ -35,9 +39,10 @@ export function presenceAtStart(here: boolean, now: number, savedAt: number | nu
/** Going away or coming back at `now`; anything else changes nothing. */
export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState {
if (here === state.here) return state;
if (!here) return { ...state, here, awaySince: now };
if (!here) return { ...state, here, hereSince: null, awaySince: now };
return {
here,
hereSince: now,
awaySince: null,
lastReturn: { from: state.awaySince ?? now, to: now },
returns: state.returns + 1,
@ -46,6 +51,7 @@ export function presenceChange(state: PresenceState, here: boolean, now: number)
export const usePresence = create<PresenceState>(() => ({
here: true,
hereSince: null,
awaySince: null,
lastReturn: null,
returns: 0,
@ -81,17 +87,57 @@ export function platformPresence(): PresenceHost {
return Capacitor.isNativePlatform() ? nativePresence : browserPresence;
}
/**
* When the player went away, kept on the device: the game keeps running in a
* hidden tab (and saving), so the save's last time seen can be later than
* that. Null while the player is here.
*/
export interface PresenceMemory {
read(): number | null;
write(awaySince: number | null): void;
}
const AWAY_KEY = "deadhead:away-since";
/** This device's storage; with storage blocked, nothing is remembered. */
export const devicePresenceMemory: PresenceMemory = {
read() {
try {
const value = Number(localStorage.getItem(AWAY_KEY));
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
},
write(awaySince) {
try {
if (awaySince === null) localStorage.removeItem(AWAY_KEY);
else localStorage.setItem(AWAY_KEY, String(awaySince));
} catch {
// Storage blocked: a cold start falls back to the save's last time seen.
}
},
};
/**
* Tracks presence into `usePresence` (CR-028 Step 28.2), with times from the
* game's clock so they compare with logbook times. Returns a stop function.
* game's clock so they compare with logbook times. A cold start comes back
* from when the player went away, if this device remembers, else from the
* save's last time seen. Returns a stop function.
*/
export function startPresence(
host: PresenceHost,
now: () => number,
savedAt: number | null,
memory: PresenceMemory = devicePresenceMemory,
): () => void {
usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt));
return host.onChange((here) => {
usePresence.setState((state) => presenceChange(state, here, now()));
const here = host.isHere();
const awaySince = memory.read() ?? savedAt;
const start = presenceAtStart(here, now(), awaySince);
usePresence.setState(start);
memory.write(start.awaySince);
return host.onChange((isHere) => {
usePresence.setState((state) => presenceChange(state, isHere, now()));
memory.write(usePresence.getState().awaySince);
});
}

View File

@ -6,6 +6,7 @@ import { logbookKey } from "../components/AwaySummary";
import { PilotLogbook, pilotLogbook } from "../components/PilotLogbook";
import { useLoadedPlayer } from "../state/actions";
import { Panel, Screen, ScreenHeader } from "../ui";
import { usePresence } from "../platform/presence";
const HOUR = 3_600_000;
@ -26,6 +27,7 @@ export function Logbook() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const [params, setParams] = useSearchParams();
const lastReturn = usePresence((p) => p.lastReturn);
const range = (RANGES.find(([value]) => value === params.get("range"))?.[0] ?? "24h") as LogRange;
const pilotId = params.get("pilot") ?? "";
const book = useQuery({
@ -42,11 +44,11 @@ export function Logbook() {
let body = <p className="text-ink-400">Reading the logbook…</p>;
if (book.data) {
const pending = state.summaries.length > 0;
// The absence the player last came back from (CR-028); before any, the save's own.
const window =
range === "absence"
? pending
? book.data.absence
: (book.data.lastAbsence ?? book.data.absence)
? (lastReturn ??
(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),

View File

@ -0,0 +1,68 @@
import { create } from "zustand";
import { clientConfig } from "../client-config";
/**
* A toast (decision 0.5.161): a few words that close on their own, and where
* tapping it goes. A batch toast sums Independent pilots' routine deliveries.
*/
export interface Toast {
id: string;
text: string;
tone: "good" | "info" | "warn";
/** The screen tapping it opens. */
to: string;
/** Independent deliveries combined into this toast (decision 0.5.161). */
batch?: { count: number; net: number };
/** When it was shown (device time), for batching. */
shownAt: number;
}
export type NewToast = Omit<Toast, "id" | "shownAt">;
interface ToastStore {
/** Newest first. */
toasts: Toast[];
push(toasts: readonly NewToast[], now?: number): void;
dismiss(id: string): void;
/** "+N more" opens the Logbook: the toasts it stood for go. */
dropOverflow(): void;
}
let nextId = 1;
/** "3 deliveries · +4,200 cr". */
export function batchText(count: number, net: number): string {
const sign = net >= 0 ? "+" : "−";
return `${count} deliveries · ${sign}${Math.abs(net).toLocaleString("en-US")} cr`;
}
export const useToasts = create<ToastStore>((set) => ({
toasts: [],
push(list, now = Date.now()) {
set(({ toasts }) => {
let next = [...toasts];
for (const toast of list) {
// Independent deliveries close together share one toast (decision 0.5.161).
const open = toast.batch
? next.find((t) => t.batch && now - t.shownAt < clientConfig.toasts.batchWindowMs)
: undefined;
if (open && toast.batch) {
const count = open.batch!.count + toast.batch.count;
const net = open.batch!.net + toast.batch.net;
next = next.map((t) =>
t === open ? { ...t, batch: { count, net }, text: batchText(count, net) } : t,
);
continue;
}
next = [{ ...toast, id: `toast-${nextId++}`, shownAt: now }, ...next];
}
return { toasts: next };
});
},
dismiss(id) {
set(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) }));
},
dropOverflow() {
set(({ toasts }) => ({ toasts: toasts.slice(0, clientConfig.toasts.maxVisible) }));
},
}));

View File

@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({
@ -59,6 +59,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
describe("something waiting on return", () => {
it("highlights a goal ready to claim (claimed on the Bridge's goals card, CR-025)", async () => {
comeBack();
let acknowledged = false;
const api = fakeApi({
getPlayerState: () => returningPlayer(acknowledged),
@ -79,6 +80,7 @@ describe("something waiting on return", () => {
});
it("does not mention goals when none are ready", async () => {
comeBack();
const state = returningPlayer(false);
const quiet = {
...state,

View File

@ -0,0 +1,188 @@
import type { LogEntry, PlayerState, Ship } from "@deadhead/schema";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { presenceChange, usePresence } from "../src/platform/presence";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const MINUTE = 60_000;
const T = Date.now();
const ship: Ship = {
id: "s1",
name: "Wren",
modelId: "courier",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: "avatar",
lastArrivalAt: 0,
...shipExtras("courier"),
};
const state = (): PlayerState =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 },
ships: [ship],
pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: "s1", locationId: null }],
});
const delivery = (id: string, at: number, goodId = "consumer-goods"): LogEntry => ({
id,
at,
pilotId: "avatar",
shipId: "s1",
seen: false,
kind: "flight",
dispatchId: id,
deadhead: false,
tutorial: false,
independent: false,
originId: "earth",
destinationId: "moon",
goodId,
massTonnes: 5,
shipper: "Pacific Orbital Logistics",
payout: 1_000,
pilotPay: 0,
fuelCost: 100,
loanRepayment: 0,
xp: 5,
scriptedId: null,
});
/** A fake engine whose logbook remembers what was marked seen. */
function game(entries: LogEntry[]) {
const book = entries.map((e) => ({ ...e }));
const api = fakeApi({
getPlayerState: () => state(),
getLogbook: () => ({
entries: book.map((e) => ({ ...e })),
absence: { from: T - 60 * MINUTE, to: T },
lastAbsence: null,
problems: [],
}),
markLogbookSeen: ({ entryIds }) => {
let marked = 0;
for (const e of book) {
if (entryIds.includes(e.id) && !e.seen) {
e.seen = true;
marked++;
}
}
return { marked };
},
});
return { api, book };
}
/** The player has been here since `since`. */
function hereSince(since: number) {
usePresence.setState({
here: true,
hereSince: since,
awaySince: null,
lastReturn: null,
returns: 0,
});
}
const goAway = (at: number) => act(() => usePresence.setState((p) => presenceChange(p, false, at)));
const comeBackAt = (at: number) =>
act(() => usePresence.setState((p) => presenceChange(p, true, at)));
const popup = () => screen.queryByRole("dialog", { name: /While you were away/ });
const notifications = () => screen.findByRole("region", { name: "Notifications" });
describe("the pop-up only after being away (Step 28.4, decision 0.5.162)", () => {
it("toasts a delivery while the game is on screen, with no pop-up", async () => {
hereSince(T - 10 * MINUTE);
const { api, book } = game([delivery("d1", T - MINUTE)]);
renderApp(api, "/fleet");
const region = await notifications();
await waitFor(() => expect(region).toHaveTextContent("Wren delivered Consumer Goods to Moon"));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book[0]!.seen).toBe(true));
});
it("after 5 minutes away, shows exactly what happened while away, and not again after a reload", async () => {
hereSince(T - 30 * MINUTE);
const { api, book } = game([
{ ...delivery("seen", T - 20 * MINUTE), seen: true },
delivery("before", T - 15 * MINUTE, "helium-3"),
]);
// "before" was toasted while here; then the tab is hidden for 5 minutes.
book[1]!.seen = true;
const view = renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 5 * MINUTE);
// Two deliveries land while the tab is hidden.
book.push(
delivery("d1", T - 4 * MINUTE, "mail"),
delivery("d2", T - 2 * MINUTE, "spare-parts"),
);
comeBackAt(T);
const sheet = await screen.findByRole("dialog", { name: /While you were away · 5 m/ });
const list = await within(sheet).findByRole("list", { name: "What happened" });
expect(
within(list)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
expect.stringMatching(/delivered Mail & Parcels to Moon/),
expect.stringMatching(/delivered Spare Parts to Moon/),
]);
await userEvent.click(within(sheet).getByRole("button", { name: "Done" }));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book.every((e) => e.seen)).toBe(true));
// A reload: a cold start counts as coming back, but there's nothing unseen.
view.unmount();
usePresence.setState({
here: true,
hereSince: T,
awaySince: null,
returns: 0,
lastReturn: null,
});
act(() =>
usePresence.setState({ lastReturn: { from: T - 10 * MINUTE, to: T + 1 }, returns: 1 }),
);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
await waitFor(() => expect(api.call).toHaveBeenCalledWith("getLogbook", {}));
await new Promise((r) => setTimeout(r, 50));
expect(popup()).not.toBeInTheDocument();
});
it("after 30 seconds away, shows the news as toasts instead", async () => {
hereSince(T - 30 * MINUTE);
const { api, book } = game([delivery("d1", T - 20_000)]);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 30_000);
comeBackAt(T);
const region = await notifications();
await waitFor(() => expect(region).toHaveTextContent("Wren delivered Consumer Goods to Moon"));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book[0]!.seen).toBe(true));
});
it("shows nothing on coming back when nothing happened", async () => {
hereSince(T - 30 * MINUTE);
const { api } = game([{ ...delivery("old", T - 20 * MINUTE), seen: true }]);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 10 * MINUTE);
comeBackAt(T);
await waitFor(() =>
expect(api.call.mock.calls.filter(([name]) => name === "getLogbook").length).toBeGreaterThan(
1,
),
);
await new Promise((r) => setTimeout(r, 50));
expect(popup()).not.toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Notifications" })).queryAllByRole("listitem"),
).toEqual([]);
});
});

View File

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatAbsence } from "../src/components/AwaySummary";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T12:00:00Z");
const FROM = NOW - (9 * 60 + 40) * 60_000;
@ -87,6 +87,8 @@ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}):
}) as LogEntry;
function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
const absence = over.absence ?? { from: FROM, to: NOW };
comeBack({ from: absence.from, to: absence.to + 1 });
let seen = false;
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
const api = fakeApi({

View File

@ -4,7 +4,7 @@ import { act, 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, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -140,6 +140,7 @@ describe("loan on HQ", () => {
});
it("shows the lender's cut in a delivery summary", async () => {
comeBack();
const state: PlayerState = {
...owner(2000, 4000),
summaries: [

View File

@ -2,7 +2,14 @@ import { content } from "@deadhead/content";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render";
import {
fakeApi,
renderApp,
samplePlayerState,
sampleTutorialBoard,
shipExtras,
comeBack,
} from "./render";
const offered = () =>
samplePlayerState({
@ -90,6 +97,7 @@ describe("the deal", () => {
});
it("waits until the delivery summary is dismissed", async () => {
comeBack();
const withSummary = () => ({
...offered(),
summaries: [

View File

@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
import { sleepLabel } from "../src/components/FleetSleep";
import { independentStatus } from "../src/components/IndependentPilots";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-03T12:00:00Z");
const SLEPT = Date.parse("2100-01-02T09:30:00Z");
@ -120,6 +120,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
});
it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => {
comeBack();
const api = sleepyApi({ summaries: [summary] });
renderApp(api, "/");
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
@ -134,6 +135,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
});
it("leaves the banner off the away screen when the fleet didn't go quiet", async () => {
comeBack();
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();

View File

@ -5,7 +5,7 @@ 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";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-08T12:00:00Z");
const HOUR = 3_600_000;
@ -243,6 +243,7 @@ describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
});
it("opens from View logbook on This absence, with the pop-up's totals", async () => {
comeBack({ from: ABSENCE.from, to: ABSENCE.to + 1 });
renderApp(app(false), "/");
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
const figures = await within(popup).findByLabelText("Company totals");

View File

@ -7,7 +7,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatCredits } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -132,6 +132,7 @@ describe("pilot share in the client (Step 10J.6)", () => {
});
it("takes the pilot's share out of a delivery's net in the summary", async () => {
comeBack();
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
// 600 cr after the share, less 150 cr of fuel (CR-025); the share itself is in the Logbook.

View File

@ -6,10 +6,24 @@ import {
startPresence,
usePresence,
type PresenceHost,
type PresenceMemory,
} from "../src/platform/presence";
const T = 1_000_000;
/** Device memory for the time the player went away. */
function fakeMemory(value: number | null = null): PresenceMemory & { value: number | null } {
return {
value,
read() {
return this.value;
},
write(awaySince) {
this.value = awaySince;
},
};
}
/** A host whose visibility the test flips. */
function fakeHost(here = true) {
let listener: ((here: boolean) => void) | null = null;
@ -45,7 +59,13 @@ afterEach(() => {
describe("presence (Step 28.2, decision 0.5.160)", () => {
it("records when the player went away and came back", () => {
let state = presenceAtStart(true, T, null);
expect(state).toEqual({ here: true, awaySince: null, lastReturn: null, returns: 0 });
expect(state).toEqual({
here: true,
hereSince: T,
awaySince: null,
lastReturn: null,
returns: 0,
});
state = presenceChange(state, false, T + 1_000);
expect(state).toMatchObject({ here: false, awaySince: T + 1_000 });
// Hidden again (a second event) changes nothing.
@ -53,6 +73,7 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
state = presenceChange(state, true, T + 301_000);
expect(state).toEqual({
here: true,
hereSince: T + 301_000,
awaySince: null,
lastReturn: { from: T + 1_000, to: T + 301_000 },
returns: 1,
@ -62,6 +83,7 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
it("counts a cold start as coming back from the save's last time seen", () => {
expect(presenceAtStart(true, T, T - 3_600_000)).toEqual({
here: true,
hereSince: T,
awaySince: null,
lastReturn: { from: T - 3_600_000, to: T },
returns: 1,
@ -77,11 +99,14 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
it("follows the host into the store, with times from the game clock", () => {
const fake = fakeHost();
let now = T;
const stop = startPresence(fake.host, () => now, T - 60_000);
const memory = fakeMemory();
const stop = startPresence(fake.host, () => now, T - 60_000, memory);
expect(usePresence.getState().lastReturn).toEqual({ from: T - 60_000, to: T });
now = T + 10_000;
fake.set(false);
expect(usePresence.getState()).toMatchObject({ here: false, awaySince: T + 10_000 });
// Remembered on the device, in case the game is closed while hidden.
expect(memory.value).toBe(T + 10_000);
now = T + 40_000;
fake.set(true);
expect(usePresence.getState()).toMatchObject({
@ -89,10 +114,19 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
lastReturn: { from: T + 10_000, to: T + 40_000 },
returns: 2,
});
expect(memory.value).toBeNull();
stop();
expect(fake.listening).toBe(false);
});
it("comes back from when the player went away, not the later save, after closing a hidden tab", () => {
// Hidden at T − 2 h; the game kept running (and saving) until T − 1 h, then was closed.
const memory = fakeMemory(T - 2 * 3_600_000);
startPresence(fakeHost().host, () => T, T - 3_600_000, memory);
expect(usePresence.getState().lastReturn).toEqual({ from: T - 2 * 3_600_000, to: T });
expect(memory.value).toBeNull();
});
it("reads the browser tab's visibility", () => {
const seen: boolean[] = [];
const stop = browserPresence.onChange((here) => seen.push(here));

View File

@ -6,6 +6,7 @@ import { vi } from "vitest";
import { App } from "../src/App";
import { ApiError, type ApiClient } from "../src/api/client";
import { ApiProvider } from "../src/api/context";
import { usePresence } from "../src/platform/presence";
import { logbookFrom } from "./fixtures";
type Handlers = {
@ -26,6 +27,10 @@ export function fakeApi(handlers: Handlers) {
if (name === "getLogbook" && !handlers.getLogbook && handlers.getPlayerState) {
return logbookFrom(await handlers.getPlayerState({} as never));
}
// Marking entries seen (CR-028) succeeds unless the test watches it.
if (name === "markLogbookSeen" && !handlers.markLogbookSeen) {
return { marked: (input as { entryIds: string[] }).entryIds.length };
}
const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
return handler(input);
@ -39,6 +44,23 @@ export const unauthorized = () => {
export { samplePlayerState, shipExtras } from "./fixtures";
/**
* The player comes back from `absence` (CR-028): "While you were away" looks
* for unseen news from then. Call before rendering. By default, everything
* before counts as the absence.
*/
export function comeBack(
absence: { from: number; to: number } = { from: 0, to: Number.MAX_SAFE_INTEGER },
) {
usePresence.setState((p) => ({
here: true,
hereSince: absence.to,
awaySince: null,
lastReturn: absence,
returns: p.returns + 1,
}));
}
/** Renders the whole app at `path` with a fresh query cache and the given API. */
export function renderApp(api: ApiClient, path = "/") {
const queryClient = new QueryClient({

View File

@ -1,7 +1,10 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { useAwayOpen } from "../src/components/AwaySummary";
import { usePresence } from "../src/platform/presence";
import { usePreferences } from "../src/state/preferences";
import { useToasts } from "../src/state/toasts";
afterEach(() => {
cleanup();
@ -9,6 +12,16 @@ afterEach(() => {
if (typeof localStorage !== "undefined") localStorage.clear();
// One-time tips (the skill point pop-up among them) are shown again in each test.
usePreferences.setState({ dismissedTips: [] });
// Presence and toasts are module stores (CR-028): each test starts with neither.
usePresence.setState({
here: true,
hereSince: null,
awaySince: null,
lastReturn: null,
returns: 0,
});
useToasts.setState({ toasts: [] });
useAwayOpen.setState({ open: false });
});
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.

View File

@ -3,7 +3,7 @@ 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, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -29,6 +29,7 @@ describe("the shop in summaries and on HQ (Step 10I.8)", () => {
afterEach(() => vi.useRealTimers());
it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => {
comeBack();
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({

View File

@ -0,0 +1,237 @@
import type { LogEntry, PlayerState, Ship } from "@deadhead/schema";
import { flightNet } from "@deadhead/sim";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, useLocation } from "react-router";
import { entryToasts } from "../src/components/ToastFeed";
import { Toasts } from "../src/components/Toasts";
import { usePresence } from "../src/platform/presence";
import { usePreferences } from "../src/state/preferences";
import { batchText, useToasts, type NewToast } from "../src/state/toasts";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
function Where() {
return <p data-testid="where">{useLocation().pathname}</p>;
}
function renderToasts() {
return render(
<MemoryRouter initialEntries={["/fleet"]}>
<Toasts />
<Where />
</MemoryRouter>,
);
}
const toast = (text: string, over: Partial<NewToast> = {}): NewToast => ({
text,
tone: "info",
to: "/fleet",
...over,
});
function setWidth(width: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
}
const ship: Ship = {
id: "s1",
name: "Wren",
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "p1",
lastArrivalAt: 0,
...shipExtras("courier"),
};
const state = (): PlayerState =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 },
ships: [ship],
pilots: [{ ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false }],
});
function delivery(
id: string,
at: number,
over: Partial<Extract<LogEntry, { kind: "flight" }>> = {},
) {
return {
id,
at,
pilotId: "p1",
shipId: "s1",
seen: false,
kind: "flight",
dispatchId: id,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
massTonnes: 5,
shipper: "Pacific Orbital Logistics",
payout: 1_500,
pilotPay: 200,
fuelCost: 100,
loanRepayment: 0,
xp: 5,
scriptedId: null,
...over,
} satisfies LogEntry;
}
const discovery = (id: string, at: number): LogEntry => ({
id,
at,
pilotId: "p1",
shipId: "s1",
seen: false,
kind: "discovery",
locationId: "mars",
});
afterEach(() => {
vi.useRealTimers();
setWidth(1024);
});
describe("toasts on screen (Step 28.3, decision 0.5.161)", () => {
it("sit top-right on a desktop and top-center below the safe area on a phone", () => {
setWidth(1280);
act(() => useToasts.getState().push([toast("Wren delivered Mail to the Moon")]));
renderToasts();
const region = screen.getByRole("region", { name: "Notifications" });
expect(region).toHaveAttribute("data-placement", "top-right");
expect(region).toHaveAttribute("aria-live", "polite");
act(() => setWidth(375));
expect(region).toHaveAttribute("data-placement", "top-center");
expect(region.className).toContain("safe-area-inset-top");
});
it("closes after 5 seconds, but not while hovered", () => {
vi.useFakeTimers();
act(() => useToasts.getState().push([toast("First"), toast("Hovered")]));
renderToasts();
const hovered = screen.getByText("Hovered").closest("li")!;
fireEvent.mouseEnter(hovered);
act(() => vi.advanceTimersByTime(4_999));
expect(screen.getByText("First")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(1));
expect(screen.queryByText("First")).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(20_000));
expect(screen.getByText("Hovered")).toBeInTheDocument();
fireEvent.mouseLeave(hovered);
act(() => vi.advanceTimersByTime(5_000));
expect(screen.queryByText("Hovered")).not.toBeInTheDocument();
});
it("closes early with ✕ or a swipe", () => {
act(() => useToasts.getState().push([toast("Swiped"), toast("Closed")]));
renderToasts();
fireEvent.click(
within(screen.getByText("Closed").closest("li")!).getByRole("button", { name: "Dismiss" }),
);
expect(screen.queryByText("Closed")).not.toBeInTheDocument();
const swiped = screen.getByText("Swiped").closest("li")!;
fireEvent.pointerDown(swiped, { clientX: 200 });
fireEvent.pointerUp(swiped, { clientX: 300 });
expect(screen.queryByText("Swiped")).not.toBeInTheDocument();
});
it("shows at most 3, the newest on top, and the rest as +N more, which opens the Logbook", () => {
act(() => useToasts.getState().push(["One", "Two", "Three", "Four"].map((t) => toast(t))));
renderToasts();
expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual([
"Four",
"Three",
"Two",
]);
fireEvent.click(screen.getByRole("button", { name: "+1 more" }));
expect(screen.getByTestId("where")).toHaveTextContent("/logbook");
expect(screen.queryByRole("button", { name: /more/ })).not.toBeInTheDocument();
});
it("opens the related screen when tapped", () => {
act(() => useToasts.getState().push(entryToasts([discovery("x1", NOW)], state())));
renderToasts();
fireEvent.click(screen.getByRole("button", { name: /opened a new market: Mars/ }));
expect(screen.getByTestId("where")).toHaveTextContent("/locations");
expect(screen.queryByText(/opened a new market/)).not.toBeInTheDocument();
});
it("appears without sliding with reduced motion", () => {
usePreferences.setState({ reducedMotion: true });
act(() => useToasts.getState().push([toast("Still")]));
renderToasts();
expect(screen.getByRole("region", { name: "Notifications" })).toHaveAttribute(
"data-motion",
"none",
);
expect(screen.getByText("Still").closest("li")!.className).not.toMatch(/animate-/);
usePreferences.setState({ reducedMotion: null });
});
});
describe("what gets a toast (Step 28.3)", () => {
it("combines Independent deliveries within 10 seconds, and gives a discovery its own", () => {
const entries = [delivery("d1", NOW), delivery("d2", NOW + 1_000), discovery("x1", NOW + 2)];
act(() => useToasts.getState().push(entryToasts(entries, state()), NOW));
// A third delivery 8 seconds later joins the same toast.
act(() =>
useToasts.getState().push(entryToasts([delivery("d3", NOW + 8_000)], state()), NOW + 8_000),
);
const net = 3 * flightNet(delivery("d", 0));
expect(useToasts.getState().toasts.map((t) => t.text)).toEqual([
"Rosa opened a new market: Mars",
batchText(3, net),
]);
// Past the window, a new toast starts.
act(() => useToasts.getState().push(entryToasts([delivery("d4", NOW)], state()), NOW + 20_000));
expect(useToasts.getState().toasts).toHaveLength(3);
});
it("gives a delivery the player sent its own toast, and none to a deadhead", () => {
const toasts = entryToasts(
[
delivery("d1", NOW, { independent: false }),
delivery("d2", NOW, { independent: false }),
delivery("d3", NOW, { deadhead: true, goodId: null, payout: 0 }),
],
state(),
);
expect(toasts).toHaveLength(2);
expect(toasts.every((t) => t.batch === undefined)).toBe(true);
});
it("toasts the unseen entries from while the player is here and marks them seen", async () => {
usePresence.setState({ here: true, hereSince: NOW - 60_000 });
const before = { ...discovery("old", NOW - 120_000) };
const api = fakeApi({
getPlayerState: () => state(),
getLogbook: () => ({
entries: [
before,
delivery("d1", NOW - 10_000, { independent: false }),
discovery("x1", NOW),
],
absence: { from: NOW - 60_000, to: NOW },
lastAbsence: null,
problems: [],
}),
});
renderApp(api, "/fleet");
const region = await screen.findByRole("region", { name: "Notifications" });
await waitFor(() => expect(within(region).getAllByRole("listitem")).toHaveLength(2));
expect(region).toHaveTextContent("Wren delivered");
expect(region).toHaveTextContent("opened a new market: Mars");
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("markLogbookSeen", { entryIds: ["d1", "x1"] }),
);
});
});

View File

@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../src/api/client";
import { useClock } from "../src/clock/clock";
import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const loaner = content.shipModel("company-courier")!;
@ -126,6 +126,7 @@ describe("tutorial contract board", () => {
describe("scripted delivery summary", () => {
it("shows the scripted contract's flavor message after it resolves", async () => {
comeBack();
const flavor = content.scriptedTutorialContracts.find(
(s) => s.id === "unmarked-crates",
)!.flavorMessage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -169,3 +169,7 @@ Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted bo
Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`.
Presence (CR-028 Step 28.2): `apps/web/src/platform/presence.ts` keeps `usePresence` (here, `awaySince`, `lastReturn`, `returns`) from the tab's visibility or Capacitor's app state, in game-clock time. `GameRoot` starts it with the stored save's `lastTimeSeen` (read before the resolution loop's first command), so opening the game is a return from then. Client-only settings (toast timing, the short-absence threshold) live in `apps/web/src/client-config.ts`.
Toasts (CR-028 Step 28.3): `apps/web/src/state/toasts.ts` (`useToasts`: push, dismiss, drop the overflow; batch toasts merge within `batchWindowMs`), `components/Toasts.tsx` (the stack) and `components/ToastFeed.tsx` (`entryToasts` builds toasts from logbook entries; `ToastFeed` reads `getLogbook` after each player-state refresh while here, toasts unseen entries stamped since `hereSince` and marks them seen with `markLogbookSeen`; it also toasts a booked service newly out of reach and the fleet going quiet). Both are mounted in `App`.
The away pop-up (CR-028 Step 28.4): `AwaySummary` runs on each return in `usePresence` (`returns`, `lastReturn`): it reads `getLogbook`, keeps the unseen entries stamped before the return, and shows nothing, toasts (away under `clientConfig.presence.shortAbsenceMs`) or the pop-up, then marks them seen (`markLogbookSeen`) and acknowledges the delivery summaries. `useAwayOpen` tells the other sheets to wait. `ToastFeed` acknowledges the summaries of what it toasted. Presence keeps the time the player went away in local storage (`deadhead:away-since`) for a cold start.

View File

@ -583,6 +583,6 @@ Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026
- [x] **Step 28.1 — Engine: seen tracking.** Every logbook entry has a saved `seen` flag (schema, and the sim's `LogbookEntry`); `logEntry` writes new entries unseen. Command `markLogbookSeen` (`POST /api/logbook/seen`, `{ entryIds }` → `{ marked }`), not a manual decision, so it doesn't wake the fleet. Save version 13: the 12 → 13 migration marks every existing entry seen. Tests: `packages/engine/test/logbook.test.ts` (new entries unseen; marking one, unknown ids ignored, marking again changes nothing, the fleet not woken, kept through export and import); `packages/schema/test/save.test.ts` (12 → 13); the route catalog.
- [x] **Step 28.2 — Client: presence tracking.** `apps/web/src/platform/presence.ts`: `usePresence` (here, away since, the last absence that ended, a count of comebacks) fed by `startPresence` from the Page Visibility API (`browserPresence`) or Capacitor's `appStateChange` on iOS and Android (`nativePresence`), with times from the game clock so they compare with logbook times; pure `presenceAtStart` and `presenceChange`. `GameRoot` reads the save's `lastTimeSeen` before the resolution loop moves it on, so a cold start counts as coming back from then. New `apps/web/src/client-config.ts` holds the toast timings and the 60-second short absence (CR-028 §3). Tests (`apps/web/test/presence.test.ts`): going away and coming back give the right times, repeats ignored; a cold start (and one in a background tab); the store following a host with the game clock; the browser's `visibilitychange`.
- [ ] Step 28.3 — Client: toasts
- [ ] Step 28.4 — Client: pop-up only after being away
- [x] **Step 28.3 — Client: toasts.** `apps/web/src/state/toasts.ts` (the store: newest first; an Independent delivery joins a batch toast shown in the last 10 s, "3 deliveries · +4,200 cr"), `components/Toasts.tsx` (top-right at 768 px and wider, top-center below the safe area on phones, both under the top bar; 5 s each, paused by hover or touch; ✕ and a 60 px swipe close early; at most 3, the rest as "+N more" opening the Logbook; tapping opens the toast's screen; `aria-live="polite"`; no slide with reduced motion) and `components/ToastFeed.tsx` (after each refresh while the player is here: the logbook's unseen entries from `hereSince` on become toasts (`entryToasts`: deliveries, discoveries, level-ups, goals ready, event outcomes, service started, finished and waiting for funds, plain upgrades and repairs; none for deadheads, refuelling, rest or automatic bookings) and are marked seen; a booked service newly out of reach and the fleet going quiet get toasts too). Timings in `client-config.ts`. The away pop-up still opens as before until Step 28.4. Tests (`apps/web/test/toasts.test.tsx`): placement at 1280 and 375 px; 5 s, not while hovered; ✕ and swipe; a fourth toast giving "+1 more" and opening the Logbook; tapping a discovery opening Places; reduced motion; three Independent deliveries within 10 s in one toast with a discovery on its own, and a new toast past the window; player deliveries alone, deadheads none; the feed toasting only entries from while here and marking them seen. Screenshots: `docs/screenshots/28-3-toasts-*`.
- [x] **Step 28.4 — Client: pop-up only after being away.** `AwaySummary` no longer opens on unacknowledged deliveries: on each return (presence `returns`, a cold start included) it reads the logbook and takes the unseen entries from before coming back (anything from while the player was here is already seen). None worth a toast: nothing shows (they're marked seen). Away under 60 s: they show as toasts. Otherwise the pop-up, built from exactly those entries, marks them seen and closes the delivery summaries when it closes. `useAwayOpen` holds the deal, the skill-point prompt and radio calls back while it decides or shows. `ToastFeed` closes the summaries of deliveries and upgrades toasted while here, which the pop-up used to do. Presence remembers on the device when the player went away (`devicePresenceMemory`), because a hidden tab keeps running and saving: a cold start comes back from then, else from the save's last time seen. The Logbook's "This absence" is the absence the player last came back from. End-to-end helpers: `goAway` (the tab hidden), `expectNews` (a toast or the pop-up), and `dismissLogbook` copes with either. End-to-end suite green locally (11 tests). Screenshots: `docs/screenshots/28-4-away-*`. Tests (`apps/web/test/away-return.test.tsx`): a delivery while here is a toast with no pop-up; 5 minutes hidden with two deliveries shows the pop-up with exactly those two, and a reload afterwards shows nothing; 30 seconds hidden gives toasts; coming back to nothing shows nothing; `presence.test.ts` (closing a hidden tab). Tests that open the pop-up now come back first (`comeBack` in `test/render.tsx`).
- [ ] Step 28.5 — Regression