diff --git a/DECISIONS.md b/DECISIONS.md index abce76f..a480a44 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d1e3e54..cc129f4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { + {/* What happens while the player watches (CR-028). */} + + diff --git a/apps/web/src/components/PilotLogbook.tsx b/apps/web/src/components/PilotLogbook.tsx index 790328a..0353a50 100644 --- a/apps/web/src/components/PilotLogbook.tsx +++ b/apps/web/src/components/PilotLogbook.tsx @@ -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)} ); - case "service": { - const items = serviceItems({ ...entry, upgradeLevel: null }); - const where = locationName(entry.hqId); - const ship = shipName(state, entry.shipId); + case "service": return ( - {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))} ); - } case "goal-ready": return ( diff --git a/apps/web/src/components/Service.tsx b/apps/web/src/components/Service.tsx index dce2c17..ea60db8 100644 --- a/apps/web/src/components/Service.tsx +++ b/apps/web/src/components/Service.tsx @@ -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, 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) { diff --git a/apps/web/src/components/ToastFeed.tsx b/apps/web/src/components/ToastFeed.tsx new file mode 100644 index 0000000..14ed10a --- /dev/null +++ b/apps/web/src/components/ToastFeed.tsx @@ -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 { + 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()); + const problems = useRef | 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; +} diff --git a/apps/web/src/components/Toasts.tsx b/apps/web/src/components/Toasts.tsx new file mode 100644 index 0000000..f6dba36 --- /dev/null +++ b/apps/web/src/components/Toasts.tsx @@ -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 = { + 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(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 ( +
  • 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") : ""}`} + > + + +
  • + ); +} + +/** + * 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