Step 28.3: client: toasts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 14:33:11 -04:00
parent b431969243
commit 43859765eb
16 changed files with 683 additions and 18 deletions

View File

@ -2058,3 +2058,11 @@ 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. - **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). - **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. - **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.

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { content } from "@deadhead/content"; 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 { nearestReachableHq, nextUpgradeLevel, repairCost, upgradeCost } from "@deadhead/sim";
import { useState } from "react"; import { useState } from "react";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
@ -20,6 +20,22 @@ export function serviceItems(booking: Pick<Booking, "repair" | "upgradeId" | "up
return items.join(" + "); 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. */ /** The Fleet card and dispatch sheet tag (CR-027 §4); null when there's nothing to say. */
export function serviceTag(ship: Ship): string | null { export function serviceTag(ship: Ship): string | null {
if (ship.booking) { if (ship.booking) {

View File

@ -0,0 +1,164 @@
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 { 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]);
// 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 { export interface PresenceState {
here: boolean; here: boolean;
/** Since when the player has been here; null while away. */
hereSince: number | null;
awaySince: number | null; awaySince: number | null;
lastReturn: Absence | null; lastReturn: Absence | null;
returns: number; 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. */ /** 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 { 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 { return {
here, here,
hereSince: now,
awaySince: null, awaySince: null,
lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null, lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null,
returns: savedAt !== null && savedAt < now ? 1 : 0, 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. */ /** Going away or coming back at `now`; anything else changes nothing. */
export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState { export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState {
if (here === state.here) return state; if (here === state.here) return state;
if (!here) return { ...state, here, awaySince: now }; if (!here) return { ...state, here, hereSince: null, awaySince: now };
return { return {
here, here,
hereSince: now,
awaySince: null, awaySince: null,
lastReturn: { from: state.awaySince ?? now, to: now }, lastReturn: { from: state.awaySince ?? now, to: now },
returns: state.returns + 1, returns: state.returns + 1,
@ -46,6 +51,7 @@ export function presenceChange(state: PresenceState, here: boolean, now: number)
export const usePresence = create<PresenceState>(() => ({ export const usePresence = create<PresenceState>(() => ({
here: true, here: true,
hereSince: null,
awaySince: null, awaySince: null,
lastReturn: null, lastReturn: null,
returns: 0, returns: 0,

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

@ -45,7 +45,13 @@ afterEach(() => {
describe("presence (Step 28.2, decision 0.5.160)", () => { describe("presence (Step 28.2, decision 0.5.160)", () => {
it("records when the player went away and came back", () => { it("records when the player went away and came back", () => {
let state = presenceAtStart(true, T, null); 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); state = presenceChange(state, false, T + 1_000);
expect(state).toMatchObject({ here: false, awaySince: T + 1_000 }); expect(state).toMatchObject({ here: false, awaySince: T + 1_000 });
// Hidden again (a second event) changes nothing. // Hidden again (a second event) changes nothing.
@ -53,6 +59,7 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
state = presenceChange(state, true, T + 301_000); state = presenceChange(state, true, T + 301_000);
expect(state).toEqual({ expect(state).toEqual({
here: true, here: true,
hereSince: T + 301_000,
awaySince: null, awaySince: null,
lastReturn: { from: T + 1_000, to: T + 301_000 }, lastReturn: { from: T + 1_000, to: T + 301_000 },
returns: 1, returns: 1,
@ -62,6 +69,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", () => { it("counts a cold start as coming back from the save's last time seen", () => {
expect(presenceAtStart(true, T, T - 3_600_000)).toEqual({ expect(presenceAtStart(true, T, T - 3_600_000)).toEqual({
here: true, here: true,
hereSince: T,
awaySince: null, awaySince: null,
lastReturn: { from: T - 3_600_000, to: T }, lastReturn: { from: T - 3_600_000, to: T },
returns: 1, returns: 1,

View File

@ -26,6 +26,10 @@ export function fakeApi(handlers: Handlers) {
if (name === "getLogbook" && !handlers.getLogbook && handlers.getPlayerState) { if (name === "getLogbook" && !handlers.getLogbook && handlers.getPlayerState) {
return logbookFrom(await handlers.getPlayerState({} as never)); 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; const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`); if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
return handler(input); return handler(input);

View File

@ -1,7 +1,9 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react"; import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest"; import { afterEach, vi } from "vitest";
import { usePresence } from "../src/platform/presence";
import { usePreferences } from "../src/state/preferences"; import { usePreferences } from "../src/state/preferences";
import { useToasts } from "../src/state/toasts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@ -9,6 +11,15 @@ afterEach(() => {
if (typeof localStorage !== "undefined") localStorage.clear(); if (typeof localStorage !== "undefined") localStorage.clear();
// One-time tips (the skill point pop-up among them) are shown again in each test. // One-time tips (the skill point pop-up among them) are shown again in each test.
usePreferences.setState({ dismissedTips: [] }); 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: [] });
}); });
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s. // findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.

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"] }),
);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -169,3 +169,5 @@ 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`. 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`. 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`.

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.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`. - [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 - [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-*`.
- [ ] Step 28.4 — Client: pop-up only after being away - [ ] Step 28.4 — Client: pop-up only after being away
- [ ] Step 28.5 — Regression - [ ] Step 28.5 — Regression