diff --git a/CHANGELOG.md b/CHANGELOG.md index feb31c5..6015cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.17.0 — 2026-09-19 14:49 +- **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css` +- **Event messages via snapshot diffing.** No server event feed exists, so new pure `lib/deriveEvents.ts` — `diffSnapshots(prev: StateSnapshot | null, next) → {kind:'info', message}[]` — derives human-readable events from the fields already in `/api/state`: plane landing (`flying→available`, "ATR 72-600 landed at Frankfurt"), package delivery (`loaded→delivered`, "Package delivered: +€X" with the reward), expiry (`open|loaded→expired`, "Package expired: A → B"), new packages (id only in next, "New package available: A → B") and planes bought/sold (plane id set changed, "Bought/Sold {model}"). The shell keeps a `prevRef` and diffs on every snapshot change, pushing each event as an info toast; the first snapshot after load emits nothing (no baseline — no page-load spam) +- **Browser notifications when the window is not focused.** Each derived event also fires `new Notification('Flight Ops', {body})` when `document.hidden && 'Notification' in window && Notification.permission === 'granted'`. The left-pane Time section gains an explicit opt-in button (`[data-testid="enable-notifications"]`) calling `Notification.requestPermission()`; its label reflects the result ("Enable notifications" → "Notifications enabled"/"Notifications blocked"), and it renders disabled with a tooltip title when the API is absent. SSR-safe: permission state starts null and is read in an effect after hydration +- No server or OpenAPI changes — diffing uses only existing snapshot fields (verified: planes by id/status/locationName, packages by id/status/reward/origin+dest names) +- Tests: unit — new `lib/deriveEvents.test.ts` suite pins every event format (landing, delivery with reward, expiry from open and loaded, new-package appearance, bought/sold, identical snapshots → [], first snapshot → [], non-events like takeoff/load ignored); `lib/useToasts.test.ts` covers the queue cap. e2e — new `e2e/toasts.spec.ts`: deterministic insufficient-funds path (select the most expensive model in `[data-testid="market-buy-model"]`, click Buy, expect a visible error toast matching /insufficient funds/ AND the inline `right-error` still showing; skips only if cash already covers the plane on a long-lived local instance) and the notifications opt-in (unsupported → disabled + title; otherwise fresh context shows "Enable notifications" and after clicking the label reflects the result — soft, since headless CI may deny permission) +- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack + ## v0.16.1 — 2026-09-19 14:00 - **Smooth client-side sim clock with per-snapshot re-sync (ISS-007).** The left-pane "Time" display used to jump once per 1 s poll because it rendered the snapshot's `clockMs` verbatim. It now ticks smoothly on the client between polls: new hook `lib/useSimClock.ts` keeps an anchor `{clockMs, at, timeScale, paused}` and re-derives the display clock every ~100 ms as `anchor.clockMs + (now − at) × timeScale` — exactly the server's own tick model (`tickOnce`: real-time × timeScale), so client extrapolation mirrors authoritative game time. Every new snapshot re-anchors to server truth, bounding any drift by network latency only; while paused the display is frozen at the anchor value (advance 0). The pure core `advanceSimClock(anchor, nowMs)` is exported and unit-tested in node - **LeftPane consumes it for the `sim-clock` paragraph ONLY** (`components/LeftPane.tsx`): fed with EFFECTIVE controls `{timeScale: pendingScale ?? snapshot scale, paused}` so a slider drag or pause click takes effect on click/drag without waiting for a confirming snapshot; the `(paused)` suffix keeps coming from the effective paused flag. Right-pane deadline/penalty previews are untouched — they stay computed from the snapshot's `clockMs` (deterministic against server truth) diff --git a/app/globals.css b/app/globals.css index 5352288..4fa72e1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -347,3 +347,70 @@ body { padding: 6px 0 0; border-top: 1px solid rgba(148, 163, 184, 0.2); } + +/* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */ +.toasts { + position: fixed; + bottom: 12px; + right: 12px; + z-index: 50; /* above map popups/controls and the app header */ + display: flex; + flex-direction: column; + gap: 8px; + max-width: 340px; +} + +.toast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + background: rgba(15, 23, 42, 0.95); + color: #e2e8f0; + border: 1px solid rgba(148, 163, 184, 0.4); +} + +.toast-error { + border-color: rgba(239, 68, 68, 0.7); + color: #fecaca; +} + +.toast-info { + border-color: rgba(148, 163, 184, 0.5); +} + +.toast-close { + flex-shrink: 0; + font-size: 14px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + border: none; + background: transparent; + color: #94a3b8; + cursor: pointer; +} + +.toast-close:hover { + color: #e2e8f0; +} + +/* ISS-008 — the Time section's notifications opt-in button (same look as .time-controls buttons) */ +.notif-toggle { + margin-top: 8px; + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(30, 41, 59, 0.9); + color: #e2e8f0; + cursor: pointer; +} + +.notif-toggle:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/app/page.tsx b/app/page.tsx index 105c63d..893be6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,14 @@ 'use client'; import dynamic from 'next/dynamic'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import LeftPane from '@/components/LeftPane'; import RightPane from '@/components/RightPane'; +import ToastStack from '@/components/Toasts'; import type { HoverTarget } from '@/components/hoverTypes'; import { useGameState } from '@/lib/useGameState'; +import { useToasts } from '@/lib/useToasts'; +import { diffSnapshots } from '@/lib/deriveEvents'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); @@ -19,12 +22,32 @@ export default function Home() { // either pane highlights the matching plane/route/airports on the map. const [hovered, setHovered] = useState(null); + // ISS-008 (v0.17.0): bottom-right toasts — action errors from both panes plus game events + // derived by diffing consecutive snapshots (no server event feed exists). + const { toasts, push, dismiss } = useToasts(); + const onError = (message: string) => push('error', message); + + // Diff each new snapshot against the previous one → info toasts. When the window is hidden + // and notifications are granted, mirror them as browser notifications too. + const prevSnapshotRef = useRef(null); + useEffect(() => { + if (state === null) return; + for (const event of diffSnapshots(prevSnapshotRef.current, state)) { + push('info', event.message); + if (document.hidden && 'Notification' in window && Notification.permission === 'granted') { + new Notification('Flight Ops', { body: event.message }); + } + } + prevSnapshotRef.current = state; + }, [state, push]); + return (
- + {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */} - + +
); } diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index cb5f100..a0c1477 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -16,6 +16,8 @@ interface LeftPaneProps { onRefresh: () => void; // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. onHover: (target: HoverTarget | null) => void; + // ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast. + onError?: (message: string) => void; } // Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest @@ -46,7 +48,7 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca return pendingScale !== null && serverTimeScale === pendingScale; } -export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) { +export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) { const [busy, setBusy] = useState(false); // POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new @@ -90,6 +92,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }: } } catch (err) { console.error('failed to apply sim controls', err); + // ISS-008: surface the failure as an error toast too — previously console-only. + onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`); queuedRef.current = null; // don't replay a stale intent after a failed round-trip } finally { busyRef.current = false; @@ -97,7 +101,7 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }: } return sentOwn ? 'sent' : 'failed'; }, - [onRefresh], + [onRefresh, onError], ); const paused = state?.simState.paused ?? false; @@ -166,6 +170,25 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }: [], ); + // ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read + // in an effect after hydration, so server and client render identically) and follows the + // result of Notification.requestPermission(). 'unsupported' = no Notification API at all. + const [notifPermission, setNotifPermission] = useState(null); + useEffect(() => { + if ('Notification' in window) setNotifPermission(Notification.permission); + else setNotifPermission('unsupported'); + }, []); + + // null (pre-effect) and 'default' both read as the opt-in prompt. + const notifLabel: string = + notifPermission === 'granted' + ? 'Notifications enabled' + : notifPermission === 'denied' + ? 'Notifications blocked' + : notifPermission === 'unsupported' + ? 'Notifications unavailable' + : 'Enable notifications'; + const displayedScale = pendingScale ?? timeScale; // ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every @@ -253,6 +276,24 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }: /> {displayedScale}× + {/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */} + {error && ( diff --git a/components/RightPane.tsx b/components/RightPane.tsx index d2009e2..216992b 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -16,6 +16,9 @@ interface RightPaneProps { // ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or // the package's endpoint airports. onHover: (target: HoverTarget | null) => void; + // ISS-008 (v0.17.0): report a failed action to the shell as an error toast — in ADDITION + // to the inline pane error, which stays (both surfaces by design). + onError?: (message: string) => void; } const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model; @@ -104,7 +107,7 @@ function LoadControls({ ); } -export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) { +export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) { const [busy, setBusy] = useState(false); // Inline error from a failed action — auto-clears on the next successful poll (the shell's // snapshot prop changes every tick) or the next successful action. @@ -139,12 +142,14 @@ export default function RightPane({ state, loading, applySnapshot, onHover }: Ri setActionError(null); applySnapshot(data); } catch (err) { - setActionError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + setActionError(message); // inline pane error stays… + onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008) } finally { setBusy(false); } }, - [applySnapshot, busy], + [applySnapshot, busy, onError], ); if (loading && !state) { diff --git a/components/Toasts.tsx b/components/Toasts.tsx new file mode 100644 index 0000000..6de7afd --- /dev/null +++ b/components/Toasts.tsx @@ -0,0 +1,24 @@ +'use client'; + +import type { Toast } from '@/lib/useToasts'; + +// ISS-008 (v0.17.0): the bottom-right toast stack — action errors and game events derived +// from snapshot diffs. Fixed-position overlay above the map; each item auto-dismisses via +// the hook's TTL or its × button. +export default function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss?: (id: number) => void }) { + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((t) => ( +
+ {t.message} + {onDismiss && ( + + )} +
+ ))} +
+ ); +} diff --git a/e2e/toasts.spec.ts b/e2e/toasts.spec.ts new file mode 100644 index 0000000..f653781 --- /dev/null +++ b/e2e/toasts.spec.ts @@ -0,0 +1,68 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +// v0.17.0 — ISS-008: action errors surface as bottom-right toasts (in addition to the inline +// pane error) and game events appear as info toasts via snapshot diffing; the left-pane Time +// section offers an explicit "Enable notifications" opt-in for browser notifications. + +interface CatalogEntry { + model: string; + label: string; + price: number; +} + +interface StateBody { + account: { cash: number }; + catalog: CatalogEntry[]; +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.status()).toBe(200); + return (await res.json()) as StateBody; +} + +test('failed actions toast bottom-right (inline error stays) and the Time section offers a notifications opt-in', async ({ page }) => { + await page.goto('/'); + const buyModel = page.getByTestId('market-buy-model'); + await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 }); + + // Part A — deterministic error path: the most expensive catalog model (ATR 72-600, €200k) is + // unaffordable with starting cash (€20k). On a long-lived local instance cash may have grown + // past it — then this part is skipped in place; CI's isolated stack always starts fresh. + const body = await getState(page); + const priciest = [...body.catalog].sort((a, b) => b.price - a.price)[0]; + if (body.account.cash < priciest.price) { + await buyModel.selectOption(priciest.model); + await page.getByTestId('market-buy').click(); + + // The toast appears with the server's error message and the error variant class… + const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i }); + await expect(toast, 'the error toast appears bottom-right').toBeVisible({ timeout: 10_000 }); + await expect(toast).toHaveClass(/toast-error/); + + // …and the inline pane error still shows (both surfaces by design). It auto-clears on the + // next poll (~1 s), so assert right after the toast — both are set in the same commit. + await expect(page.getByTestId('right-error')).toContainText(/insufficient funds/); + } + + // Part B — notifications opt-in in the Time section. + const btn = page.getByTestId('enable-notifications'); + await expect(btn, 'the notifications button renders in the Time section').toBeVisible({ timeout: 15_000 }); + + // Browsers without the Notification API: disabled with a tooltip-ish title. + const supported = await page.evaluate(() => 'Notification' in window); + if (!supported) { + await expect(btn).toBeDisabled(); + await expect(btn).toHaveAttribute('title', /does not support/i); + return; + } + + // Fresh context → permission starts as 'default' (the opt-in prompt)… + await expect(btn, 'fresh context shows the opt-in prompt').toHaveText('Enable notifications', { timeout: 10_000 }); + + // …and clicking requests it. Headless CI may deny — keep the assertion soft: accept either + // outcome, but the label must reflect the RESULT of the request. + await btn.click(); + await expect(btn).toHaveText(/Notifications (enabled|blocked)/i, { timeout: 10_000 }); +}); diff --git a/lib/deriveEvents.test.ts b/lib/deriveEvents.test.ts new file mode 100644 index 0000000..7f19cf2 --- /dev/null +++ b/lib/deriveEvents.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import type { PackageState, PlaneState, StateSnapshot } from './game/state'; +import { diffSnapshots } from './deriveEvents'; + +// ISS-008 (v0.17.0): snapshot-diffing event derivation — pure core of the toast/notification +// feature, unit-tested in node like advanceSimClock/fetchState. + +const CATALOG = [ + { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, capacity: 6 }, +]; + +const basePlane = (id: string): PlaneState => ({ + id, + model: 'atr72', + status: 'available', + fuel: 6300, + maxFuel: 6300, + locationIcao: 'EDDF', + locationName: 'Frankfurt', + destIcao: null, + destName: null, + departureSimTime: null, + lat: 50.03, + lon: 8.56, +}); + +const basePackage = (id: string): PackageState => ({ + id, + originIcao: 'EDDF', + originName: 'Frankfurt', + destIcao: 'LEMD', + destName: 'Madrid–Barajas', + reward: 1234.5, + cargoType: 'mail', + cargoCount: 2, + deadlineSimTime: 0, + status: 'open', + planeId: null, + createdSimTime: 0, +}); + +const snapshot = (over: Partial = {}): StateSnapshot => ({ + account: { cash: 20_000, reputation: 10 }, + simState: { clockMs: 0, timeScale: 1, paused: false }, + planes: [basePlane('plane-starter')], + packages: [], + catalog: CATALOG, + fuelPricePerL: 0.8, + sellFraction: 0.5, + ...over, +}); + +describe('diffSnapshots', () => { + it('returns [] for identical snapshots (only clock/cash drift)', () => { + const prev = snapshot(); + const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } }); + expect(diffSnapshots(prev, next)).toEqual([]); + }); + + it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => { + const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] }); + expect(diffSnapshots(null, next)).toEqual([]); + }); + + it('emits a landing event when a plane goes flying → available (new locationName is the arrival)', () => { + const prev = snapshot({ + planes: [ + { + ...basePlane('plane-starter'), + status: 'flying', + destIcao: 'LEMD', + destName: 'Madrid–Barajas', + departureSimTime: 0, + }, + ], + }); + const next = snapshot({ + planes: [ + { ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' }, + ], + }); + expect(diffSnapshots(prev, next)).toEqual([ + { kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' }, + ]); + }); + + it('emits a delivery event with the reward when a loaded package is delivered on arrival', () => { + const prev = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }] }); + const next = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }] }); + expect(diffSnapshots(prev, next)).toEqual([ + { kind: 'info', message: 'Package delivered: +€1,234.50' }, + ]); + }); + + it('emits an expiry event for open → expired and loaded → expired', () => { + const fromOpen = diffSnapshots( + snapshot({ packages: [basePackage('pkg-1')] }), + snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'expired' }] }), + ); + expect(fromOpen).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]); + + const fromLoaded = diffSnapshots( + snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'loaded', planeId: 'plane-starter' }] }), + snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'expired', planeId: 'plane-starter' }] }), + ); + expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]); + }); + + it('emits a new-package event for ids only in the next snapshot', () => { + const prev = snapshot({ packages: [basePackage('pkg-1')] }); + const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] }); + expect(diffSnapshots(prev, next)).toEqual([ + { kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' }, + ]); + }); + + it('emits bought/sold events when the plane id set changes (with the model name)', () => { + const bought = diffSnapshots( + snapshot(), + snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }), + ); + expect(bought).toEqual([{ kind: 'info', message: 'Bought ATR 72-600' }]); + + const sold = diffSnapshots( + snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }), + snapshot(), + ); + expect(sold).toEqual([{ kind: 'info', message: 'Sold ATR 72-600' }]); + }); + + it('reports every transition in one diff (planes first, then packages)', () => { + const prev = snapshot({ + planes: [ + { ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' }, + basePlane('plane-2'), + ], + packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }], + }); + const next = snapshot({ + planes: [ + { ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' }, + basePlane('plane-3'), + ], + packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }], + }); + expect(diffSnapshots(prev, next)).toEqual([ + { kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' }, + { kind: 'info', message: 'Bought ATR 72-600' }, + { kind: 'info', message: 'Sold ATR 72-600' }, + { kind: 'info', message: 'Package delivered: +€1,234.50' }, + ]); + }); + + it('ignores transitions that are not events (open → loaded, available → flying)', () => { + const prev = snapshot({ packages: [basePackage('pkg-1')] }); + const next = snapshot({ + planes: [{ ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' }], + packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }], + }); + expect(diffSnapshots(prev, next)).toEqual([]); + }); +}); diff --git a/lib/deriveEvents.ts b/lib/deriveEvents.ts new file mode 100644 index 0000000..ef1d60d --- /dev/null +++ b/lib/deriveEvents.ts @@ -0,0 +1,61 @@ +import type { StateSnapshot } from './game/state'; +import { PLANE_TYPES } from './game/catalog'; +import { formatCash } from './format'; + +// ISS-008 (v0.17.0): derive human-readable game events by diffing two consecutive /api/state +// snapshots — there is no server event feed, so the client watches for transitions in the +// fields it already receives. Pure and unit-tested; the shell calls it once per new snapshot +// and turns each result into an info toast (plus a browser notification when hidden). + +export interface GameEvent { + kind: 'info'; + message: string; +} + +const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model; + +export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] { + // No baseline yet (first snapshot after load) — nothing to compare against. Emitting a + // "new package" event for every existing row would spam the stack on page load. + if (prev === null) return []; + + const events: GameEvent[] = []; + + // Planes: landed (flying → available, new locationName is the arrival airport), bought or + // sold (id set changed — ids are stable for a plane's whole life). + const prevPlanes = new Map(prev.planes.map((p) => [p.id, p])); + for (const plane of next.planes) { + const before = prevPlanes.get(plane.id); + if (!before) { + events.push({ kind: 'info', message: `Bought ${planeName(plane.model)}` }); + continue; + } + if (before.status === 'flying' && plane.status === 'available') { + events.push({ kind: 'info', message: `${planeName(plane.model)} landed at ${plane.locationName}` }); + } + } + for (const [id, before] of prevPlanes) { + if (!next.planes.some((p) => p.id === id)) { + events.push({ kind: 'info', message: `Sold ${planeName(before.model)}` }); + } + } + + // Packages: delivered (loaded → delivered, reward paid out in the same tick), expired + // (open|loaded → expired), newly appeared (id only in next — terminal packages are never + // deleted, so an id appearing is always a genuinely new one). + const prevPackages = new Map(prev.packages.map((p) => [p.id, p])); + for (const pkg of next.packages) { + const before = prevPackages.get(pkg.id); + if (!before) { + events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` }); + continue; + } + if (before.status === 'loaded' && pkg.status === 'delivered') { + events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` }); + } else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') { + events.push({ kind: 'info', message: `Package expired: ${pkg.originName} → ${pkg.destName}` }); + } + } + + return events; +} diff --git a/lib/useToasts.test.ts b/lib/useToasts.test.ts new file mode 100644 index 0000000..0dab798 --- /dev/null +++ b/lib/useToasts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_TOASTS, pushToast, type Toast } from './useToasts'; + +// ISS-008 (v0.17.0): the toast queue's pure core — append + cap at MAX_TOASTS dropping the +// oldest. The hook itself is a thin useState wrapper around this (auto-dismiss timers are +// plain setTimeout, covered by e2e via the visible toasts). + +const toast = (id: number, message: string): Toast => ({ id, kind: 'info', message }); + +describe('pushToast', () => { + it('appends to an empty queue and preserves order', () => { + expect(pushToast([], toast(1, 'a'))).toEqual([toast(1, 'a')]); + expect(pushToast([toast(1, 'a')], toast(2, 'b'))).toEqual([toast(1, 'a'), toast(2, 'b')]); + }); + + it('keeps the newest MAX_TOASTS entries, dropping the oldest beyond the cap', () => { + let queue: Toast[] = []; + for (let id = 1; id <= MAX_TOASTS + 2; id++) queue = pushToast(queue, toast(id, `m${id}`)); + expect(queue).toEqual([toast(MAX_TOASTS - 1, 'm3'), toast(MAX_TOASTS, 'm4'), toast(MAX_TOASTS + 1, 'm5'), toast(MAX_TOASTS + 2, 'm6')]); + }); + + it('does not mutate the input queue', () => { + const before = [toast(1, 'a')]; + pushToast(before, toast(2, 'b')); + expect(before).toEqual([toast(1, 'a')]); + }); +}); diff --git a/lib/useToasts.ts b/lib/useToasts.ts new file mode 100644 index 0000000..59af146 --- /dev/null +++ b/lib/useToasts.ts @@ -0,0 +1,50 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; + +// ISS-008 (v0.17.0): transient toast notifications — a bottom-right stack for action errors +// and game events derived from snapshot diffs. Each toast auto-dismisses after TOAST_TTL_MS; +// the queue is capped at MAX_TOASTS (oldest dropped) so an event burst can't fill the corner. + +export type ToastKind = 'error' | 'info'; + +export interface Toast { + id: number; + kind: ToastKind; + message: string; +} + +export const TOAST_TTL_MS = 5_000; +export const MAX_TOASTS = 4; + +// Pure core (unit-tested): append a toast, dropping the oldest beyond the cap. +export function pushToast(toasts: Toast[], toast: Toast): Toast[] { + return [...toasts, toast].slice(-MAX_TOASTS); +} + +export interface UseToastsResult { + toasts: Toast[]; + push: (kind: ToastKind, message: string) => void; + dismiss: (id: number) => void; +} + +export function useToasts(): UseToastsResult { + const [toasts, setToasts] = useState([]); + const nextIdRef = useRef(1); + + // Functional update — a late timer for an already-dismissed/dropped id is a no-op. + const dismiss = useCallback((id: number) => { + setToasts((current) => current.filter((t) => t.id !== id)); + }, []); + + const push = useCallback( + (kind: ToastKind, message: string) => { + const toast: Toast = { id: nextIdRef.current++, kind, message }; + setToasts((current) => pushToast(current, toast)); + setTimeout(() => dismiss(toast.id), TOAST_TTL_MS); + }, + [dismiss], + ); + + return { toasts, push, dismiss }; +} diff --git a/package.json b/package.json index 8fcbcf7..b286f75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.16.1", + "version": "0.17.0", "private": true, "scripts": { "dev": "next dev -p 3000",