+ );
+}
+
+/**
+ * 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 (
+
+
+ {shown.map((toast) => (
+
+ ))}
+
+ {more > 0 && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/platform/presence.ts b/apps/web/src/platform/presence.ts
index f40e1f9..8471c0a 100644
--- a/apps/web/src/platform/presence.ts
+++ b/apps/web/src/platform/presence.ts
@@ -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(() => ({
here: true,
+ hereSince: null,
awaySince: null,
lastReturn: null,
returns: 0,
diff --git a/apps/web/src/state/toasts.ts b/apps/web/src/state/toasts.ts
new file mode 100644
index 0000000..947410e
--- /dev/null
+++ b/apps/web/src/state/toasts.ts
@@ -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;
+
+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((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) }));
+ },
+}));
diff --git a/apps/web/test/presence.test.ts b/apps/web/test/presence.test.ts
index 8aa3811..48e4b9b 100644
--- a/apps/web/test/presence.test.ts
+++ b/apps/web/test/presence.test.ts
@@ -45,7 +45,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 +59,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 +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", () => {
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,
diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx
index 1438473..d8b27d2 100644
--- a/apps/web/test/render.tsx
+++ b/apps/web/test/render.tsx
@@ -26,6 +26,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);
diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts
index 5987632..f476cc1 100644
--- a/apps/web/test/setup.ts
+++ b/apps/web/test/setup.ts
@@ -1,7 +1,9 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
+import { usePresence } from "../src/platform/presence";
import { usePreferences } from "../src/state/preferences";
+import { useToasts } from "../src/state/toasts";
afterEach(() => {
cleanup();
@@ -9,6 +11,15 @@ 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: [] });
});
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
diff --git a/apps/web/test/toasts.test.tsx b/apps/web/test/toasts.test.tsx
new file mode 100644
index 0000000..24c9534
--- /dev/null
+++ b/apps/web/test/toasts.test.tsx
@@ -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
{useLocation().pathname}
;
+}
+
+function renderToasts() {
+ return render(
+
+
+
+ ,
+ );
+}
+
+const toast = (text: string, over: Partial = {}): 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> = {},
+) {
+ 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"] }),
+ );
+ });
+});
diff --git a/docs/screenshots/28-3-toasts-desktop.png b/docs/screenshots/28-3-toasts-desktop.png
new file mode 100644
index 0000000..3657ad3
Binary files /dev/null and b/docs/screenshots/28-3-toasts-desktop.png differ
diff --git a/docs/screenshots/28-3-toasts-phone.png b/docs/screenshots/28-3-toasts-phone.png
new file mode 100644
index 0000000..d9f5bb4
Binary files /dev/null and b/docs/screenshots/28-3-toasts-phone.png differ
diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md
index 2242f6c..3ca7137 100644
--- a/memory-bank/architecture.md
+++ b/memory-bank/architecture.md
@@ -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`.
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`.
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 206c6e5..d0b6072 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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
+- [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.5 — Regression