Step 28.3: client: toasts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b431969243
commit
43859765eb
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue