From 7902d592fc58be32c90cbc8681d74c40e75fc4ec Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:14:17 +0200 Subject: [PATCH] fix(demo): let the crew view show the report the passenger actually filed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's one claim is that the crew hears about a lost item while it is still on board, and that hop was the part that was faked: /staff staged a notification hardcoded into the page on a 5s timer, so whatever a visitor reported on /, the crew saw "Schwarze Laptop-Tasche, Wagen 7, Platz 45" — the same string twice over, once in the list and once in the alert. The two halves of the demo now talk to each other. lib/demo-bus.ts carries a submitted report to the crew view through same-origin localStorage, and the `storage` event delivers it to the other tab, which is how the demo is shown (passenger on one screen, crew on another). LostItemModal publishes only when `result.item` is absent — i.e. the reporting service did not take the report, so no notification service will push it either. With a backend, the copy here would be a duplicate under a second id, so it is not written. `config.demo.enabled` is deliberately NOT the gate: it is false whenever an API URL is merely configured, including dev against a backend that is not running — exactly the case the fallback exists for. The staged notification stays for a visitor who opens /staff with no second device, but moves to mock-data as createDemoIncomingNotification(), derived from mockActiveTrip so seat and route cannot drift, and it stands down once a real report has arrived. The alert dialog renders the arriving notification instead of repeating a literal. Also: .playwright-mcp/ is git-ignored only in a personal global gitignore, so its snapshots survived into `prettier --check` and turned any browser pass into a red `verify`. Ignored in both files here. Verified end-to-end against the dev server with no backend running: reported "Rote Lesebrille im schwarzen Etui / Gepäckablage" on /, and the /staff tab already open in the same browser raised the alert with that text, that seat and the 👓 category, then took "Gefunden" on it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014MT1aaWfJeDUZpTxZ3DfHg --- .gitignore | 3 + .prettierignore | 5 + frontend/app/staff/page.tsx | 97 +++++----- .../components/passenger/LostItemModal.tsx | 8 +- frontend/jest.config.js | 3 + frontend/lib/__tests__/demo-bus.test.ts | 104 +++++++++++ frontend/lib/config.ts | 7 + frontend/lib/demo-bus.ts | 165 ++++++++++++++++++ frontend/lib/mock-data.ts | 32 ++++ 9 files changed, 380 insertions(+), 44 deletions(-) create mode 100644 frontend/lib/__tests__/demo-bus.test.ts create mode 100644 frontend/lib/demo-bus.ts diff --git a/.gitignore b/.gitignore index 25faf0d..05c96b2 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ temp/ # Per-session agent worktrees — scratch, never part of the repo. .claude/worktrees/ + +# Scratch written by browser-driving agents (Playwright MCP). +.playwright-mcp/ diff --git a/.prettierignore b/.prettierignore index 1ed5ea5..36fc6b4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,8 @@ yarn.lock # is where it is most opinionated and least useful, and it would bury the real # diff. Remove this line when you want docs formatted too. *.md + +# Scratch written by browser-driving agents (Playwright MCP snapshots and +# screenshots). Git-ignored on this machine only, so prettier — which walks the +# tree itself — is what turns a browser pass into a red `verify`. +.playwright-mcp diff --git a/frontend/app/staff/page.tsx b/frontend/app/staff/page.tsx index d53a107..949fbb8 100644 --- a/frontend/app/staff/page.tsx +++ b/frontend/app/staff/page.tsx @@ -1,20 +1,34 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { StaffHeader } from '@/components/staff/StaffHeader'; import { NotificationCard } from '@/components/staff/NotificationCard'; import { StaffStatusBar } from '@/components/staff/StaffStatusBar'; import type { StaffNotification, NotificationStatus } from '@/lib/types'; -import { mockStaff, mockVehicle } from '@/lib/mock-data'; +import { createDemoIncomingNotification, mockStaff, mockVehicle } from '@/lib/mock-data'; import { config } from '@/lib/config'; import { useDriverNotificationsApi } from '@/lib/hooks'; +import { readReports, subscribeReports } from '@/lib/demo-bus'; import { UI_LABELS } from '@/lib/labels'; +/** How an arriving report announces itself on a phone in a noisy train. */ +function buzz() { + if (typeof window !== 'undefined' && 'vibrate' in navigator) { + navigator.vibrate([200, 100, 200]); + } +} + export default function StaffPage() { - // Injected demo notification, prepended ahead of the fetched/mock list below. + // Notifications this view received locally — handed over from the passenger + // view (lib/demo-bus) or, failing that, the staged demo one. Prepended ahead + // of the fetched/mock list below. const [demoNotifications, setDemoNotifications] = useState([]); const [activeFilter, setActiveFilter] = useState<'all' | 'pending' | 'resolved'>('all'); - const [showNewNotification, setShowNewNotification] = useState(false); + // The report to shout about, and the one to keep highlighted after the + // visitor dismisses the alert. + const [arrival, setArrival] = useState(null); + const [arrivedId, setArrivedId] = useState(null); + const seenIds = useRef>(new Set()); const { data: fetchedNotifications, @@ -24,41 +38,43 @@ export default function StaffPage() { const notifications = [...demoNotifications, ...(fetchedNotifications ?? [])]; - // Simulate incoming notification for demo + const receive = useCallback((incoming: StaffNotification[], announce: boolean) => { + const fresh = incoming.filter((n) => !seenIds.current.has(n.id)); + if (fresh.length === 0) return; + + fresh.forEach((n) => seenIds.current.add(n.id)); + // Prepend rather than replace: a notification already answered here keeps + // the answer, and re-reading storage never resets it. + setDemoNotifications((prev) => [...fresh, ...prev]); + + if (announce) { + setArrival(fresh[0]); + setArrivedId(fresh[0].id); + buzz(); + } + }, []); + + // Reports the passenger view handed over. Read after mount, not during + // render: localStorage does not exist on the server, and a report already + // sitting there is history, not an arrival — only what lands while the crew + // is watching gets the alert. + useEffect(() => { + receive(readReports(), false); + return subscribeReports((reports) => receive(reports, true)); + }, [receive]); + + // Nobody on a second device: stage one report so a visitor opening /staff + // alone still sees an arrival. A real one always wins. useEffect(() => { if (!config.demo.autoNotify) return; const demoTimer = setTimeout(() => { - setShowNewNotification(true); - - const newNotification: StaffNotification = { - id: `notif-${Date.now()}`, - lostItemId: 'lost-demo', - staffId: mockStaff.id, - vehicleId: mockVehicle.id, - status: 'pending', - message: 'Schwarze Laptop-Tasche', - priority: 'urgent', - location: 'Wagen 7, Platz 45', - createdAt: new Date().toISOString(), - category: 'bags', - passengerInfo: { - tripRoute: 'Zürich HB → Bern', - tripTime: '14:32', - seatInfo: 'Wagen 7, Platz 45', - }, - }; - - setDemoNotifications((prev) => [newNotification, ...prev]); - - // Play notification sound (if available) - if (typeof window !== 'undefined' && 'vibrate' in navigator) { - navigator.vibrate([200, 100, 200]); - } + if (seenIds.current.size > 0) return; + receive([createDemoIncomingNotification()], true); }, config.timing.demoNotificationDelay); return () => clearTimeout(demoTimer); - }, []); + }, [receive]); const handleUpdateStatus = useCallback( async (notificationId: string, status: NotificationStatus, notes?: string) => { @@ -188,14 +204,14 @@ export default function StaffPage() { key={notification.id} notification={notification} onUpdateStatus={handleUpdateStatus} - isNew={index === 0 && showNewNotification && notification.status === 'pending'} + isNew={notification.id === arrivedId && notification.status === 'pending'} /> )) )} - {/* Incoming Notification Alert */} - {showNewNotification && ( + {/* Incoming Notification Alert — the report that just arrived, not a script */} + {arrival && (
@@ -203,16 +219,11 @@ export default function StaffPage() {

{UI_LABELS.staff.newLostReport}

-

- Schwarze Laptop-Tasche -

+

{arrival.message}

- Wagen 7, Platz 45 • Zürich HB → Bern + {[arrival.location, arrival.passengerInfo?.tripRoute].filter(Boolean).join(' • ')}

-
diff --git a/frontend/components/passenger/LostItemModal.tsx b/frontend/components/passenger/LostItemModal.tsx index 8154316..c5c76df 100644 --- a/frontend/components/passenger/LostItemModal.tsx +++ b/frontend/components/passenger/LostItemModal.tsx @@ -11,6 +11,7 @@ import { import { formatTime, getTimeSinceTrip } from '@/lib/mock-data'; import { config } from '@/lib/config'; import { useReportLostItem } from '@/lib/hooks'; +import { publishReport } from '@/lib/demo-bus'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; interface LostItemModalProps { @@ -54,13 +55,18 @@ export function LostItemModal({ trip, onClose, onSubmit }: LostItemModalProps) { updatedAt: new Date().toISOString(), }; + // Nothing took the report, so nothing will push it to the crew either. + // Carry it to /staff ourselves — that hop is the whole claim of the demo. + // With a backend, `result.item` exists and the notification service owns it. + if (!result.item) publishReport(newItem, trip); + setStep('success'); // Notify parent after showing success setTimeout(() => { onSubmit(newItem); }, config.timing.successMessageDelay); - }, [category, description, location, trip.id, onSubmit, reportItem]); + }, [category, description, location, trip, onSubmit, reportItem]); const handleSelectCategory = (cat: ItemCategory) => { setCategory(cat); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 9fd5bce..9bbe106 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -9,6 +9,9 @@ module.exports = { testEnvironment: 'node', testMatch: ['/lib/**/__tests__/**/*.test.ts'], + // The app resolves `@/...` through tsconfig paths; jest needs the same map or + // any module that reaches one (lib/mock-data -> @/lib/tenant) fails to load. + moduleNameMapper: { '^@/(.*)$': '/$1' }, transform: { // The app's tsconfig targets the bundler (module: esnext), which Node // cannot execute directly — override to commonjs for the test run only. diff --git a/frontend/lib/__tests__/demo-bus.test.ts b/frontend/lib/__tests__/demo-bus.test.ts new file mode 100644 index 0000000..a9b3c99 --- /dev/null +++ b/frontend/lib/__tests__/demo-bus.test.ts @@ -0,0 +1,104 @@ +/** + * The demo hand-off carries the passenger's actual report to the crew view. + * These cover the two halves that can silently lie: the mapping from report to + * notification (wrong seat or wrong urgency reads as a working demo) and the + * parser (storage is shared, long-lived, and therefore untrusted input). + */ + +import { notificationFromReport, parseReports } from '../demo-bus'; +import { config } from '../config'; +import { mockActiveTrip, mockTrips } from '../mock-data'; +import type { LostItem, StaffNotification, Trip } from '../types'; + +function report(overrides: Partial = {}): LostItem { + return { + id: 'lost-test-1', + userId: 'user-001', + tripId: mockActiveTrip.id, + category: 'bags', + description: 'Roter Rucksack mit Laptop', + location: 'overhead', + status: 'reported', + createdAt: '2026-09-01T08:30:00.000Z', + updatedAt: '2026-09-01T08:30:00.000Z', + ...overrides, + }; +} + +function tripArrivedMinutesAgo(minutes: number): Trip { + return { + ...mockActiveTrip, + arrivalTime: new Date(Date.now() - minutes * 60_000).toISOString(), + }; +} + +describe('notificationFromReport', () => { + it('carries the passenger’s own description, seat and route to the crew', () => { + const notification = notificationFromReport(report(), mockActiveTrip); + + expect(notification.message).toBe('Roter Rucksack mit Laptop'); + expect(notification.location).toBe( + `Wagen ${mockActiveTrip.car}, Platz ${mockActiveTrip.seat} • Gepäckablage`, + ); + expect(notification.passengerInfo?.tripRoute).toBe( + `${mockActiveTrip.origin.name} → ${mockActiveTrip.destination.name}`, + ); + expect(notification.category).toBe('bags'); + expect(notification.status).toBe('pending'); + expect(notification.lostItemId).toBe('lost-test-1'); + }); + + it('routes the notification to the vehicle the passenger was actually on', () => { + const otherTrip = mockTrips.find((t) => t.vehicle.id !== mockActiveTrip.vehicle.id); + expect(otherTrip).toBeDefined(); + + expect(notificationFromReport(report(), otherTrip as Trip).vehicleId).toBe( + (otherTrip as Trip).vehicle.id, + ); + }); + + it('is urgent inside the instant-alert window and normal outside it', () => { + const inside = config.reporting.instantAlertWindowMinutes - 1; + const outside = config.reporting.instantAlertWindowMinutes + 1; + + expect(notificationFromReport(report(), tripArrivedMinutesAgo(inside)).priority).toBe('urgent'); + expect(notificationFromReport(report(), tripArrivedMinutesAgo(outside)).priority).toBe( + 'normal', + ); + }); + + it('treats a trip that has not arrived yet as urgent — the item is still on board', () => { + expect(notificationFromReport(report(), tripArrivedMinutesAgo(-20)).priority).toBe('urgent'); + }); + + it('falls back to the location label when the trip has no seat reservation', () => { + const noSeat: Trip = { ...mockActiveTrip, car: undefined, seat: undefined }; + + expect(notificationFromReport(report({ location: 'bathroom' }), noSeat).location).toBe( + 'WC-Bereich', + ); + }); +}); + +describe('parseReports', () => { + it('reads back what was written', () => { + const notification = notificationFromReport(report(), mockActiveTrip); + + expect(parseReports(JSON.stringify([notification]))).toEqual([notification]); + }); + + it('yields nothing for empty, malformed or non-array storage', () => { + expect(parseReports(null)).toEqual([]); + expect(parseReports('')).toEqual([]); + expect(parseReports('{ not json')).toEqual([]); + expect(parseReports('{"id":"notif-1"}')).toEqual([]); + }); + + it('drops entries that are not usable notifications instead of rendering them', () => { + const good = notificationFromReport(report(), mockActiveTrip); + const raw = JSON.stringify([good, null, 'notif-2', { id: 'notif-3' }, { message: 'no id' }]); + + const parsed: StaffNotification[] = parseReports(raw); + expect(parsed).toEqual([good]); + }); +}); diff --git a/frontend/lib/config.ts b/frontend/lib/config.ts index 8cf32ac..aa98dfe 100644 --- a/frontend/lib/config.ts +++ b/frontend/lib/config.ts @@ -55,6 +55,13 @@ export const config = { enabled: process.env.NEXT_PUBLIC_DEMO_MODE === 'true' || API_URL === '', mockDelay: 1500, autoNotify: true, + // Where the passenger view hands a report to the crew view when no + // notification service is reachable — see lib/demo-bus.ts. Versioned so a + // future shape change cannot be handed a stale payload from a visitor's + // browser. + handoffKey: 'sbb-lf.demo.reports.v1', + // Enough to show a shift's worth of reports; the point is the newest one. + handoffLimit: 10, }, // Supported languages diff --git a/frontend/lib/demo-bus.ts b/frontend/lib/demo-bus.ts new file mode 100644 index 0000000..a694029 --- /dev/null +++ b/frontend/lib/demo-bus.ts @@ -0,0 +1,165 @@ +/** + * Demo hand-off between the passenger view and the staff view. + * + * With a backend, a report POSTed to the reporting service is fanned out to the + * crew by the notification service over its websocket. No backend is deployed + * alongside this build (`config.demo.enabled`), and the staff view covered for + * that with a notification hardcoded into the page on a 5s timer: whatever a + * visitor reported on `/`, the crew saw "Schwarze Laptop-Tasche". The one claim + * this demo exists to make — the crew hears about the item while it is still on + * board — was exactly the part that was faked. + * + * This is the smallest honest stand-in for that hop: same-origin localStorage + * carries the report, and the `storage` event delivers it to the other tab, + * which is how the demo is shown (passenger on one screen, crew on another). + * Everything here is inert when a backend is configured — see `publishReport`. + */ + +import { config } from './config'; +import { ITEM_LOCATION_CONFIG } from './types'; +import type { LostItem, StaffNotification, NotificationPriority, Trip } from './types'; +import { UI_LABELS } from './labels'; +import { mockStaff } from './mock-data'; + +/** Fired on the reporting tab itself; `storage` only reaches *other* tabs. */ +const SAME_TAB_EVENT = 'demo-report-published'; + +/** + * Where the report reaches the crew: the seat the passenger sat in, plus where + * in the vehicle they think the item is. That pair is what makes a search + * possible, so it is what the card leads with. + */ +function describeLocation(item: LostItem, trip: Trip): string { + const seat = trip.car + ? `${UI_LABELS.trip.car} ${trip.car}${trip.seat ? `, ${UI_LABELS.trip.seat} ${trip.seat}` : ''}` + : null; + const where = ITEM_LOCATION_CONFIG[item.location].labelDe; + return seat ? `${seat} • ${where}` : where; +} + +/** + * Urgency is a function of how long ago the trip ended — the whole premise of + * the product. The threshold comes from `config.reporting`, never a literal. + * A trip that has not arrived yet gives a negative age, which is the most + * urgent case there is: the item is still on board. + */ +function derivePriority(trip: Trip): NotificationPriority { + const minutesSinceArrival = (Date.now() - new Date(trip.arrivalTime).getTime()) / 60000; + return minutesSinceArrival <= config.reporting.instantAlertWindowMinutes ? 'urgent' : 'normal'; +} + +/** + * The shape the notification service would build server-side. Pure, so the + * mapping is testable without a browser. + */ +export function notificationFromReport(item: LostItem, trip: Trip): StaffNotification { + return { + id: `notif-${item.id}`, + lostItemId: item.id, + staffId: mockStaff.id, + vehicleId: trip.vehicle.id, + status: 'pending', + message: item.description, + priority: derivePriority(trip), + location: describeLocation(item, trip), + category: item.category, + createdAt: item.createdAt, + passengerInfo: { + tripRoute: `${trip.origin.name} → ${trip.destination.name}`, + tripTime: new Date(trip.departureTime).toLocaleTimeString('de-CH', { + hour: '2-digit', + minute: '2-digit', + }), + seatInfo: trip.car + ? `${UI_LABELS.trip.car} ${trip.car}${trip.seat ? `, ${UI_LABELS.trip.seat} ${trip.seat}` : ''}` + : undefined, + }, + }; +} + +/** + * Storage is shared with every other page on this origin and survives across + * builds, so anything in it is untrusted input: parse defensively and drop the + * lot rather than render half-typed objects. + */ +export function parseReports(raw: string | null): StaffNotification[] { + if (!raw) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (n): n is StaffNotification => + typeof n === 'object' && + n !== null && + typeof (n as StaffNotification).id === 'string' && + typeof (n as StaffNotification).message === 'string', + ); + } catch { + return []; + } +} + +function store(): Storage | null { + // Server render and privacy modes that throw on access both land here. + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +export function readReports(): StaffNotification[] { + const s = store(); + return s ? parseReports(s.getItem(config.demo.handoffKey)) : []; +} + +/** + * Hands a submitted report to the crew view. + * + * Call this only for a report the backend did NOT take — the caller is the one + * that knows. When the reporting service accepted it, the notification service + * owns this hop, and a copy written here would put the same report on the crew + * screen twice under two different ids. Note that `config.demo.enabled` is the + * wrong gate for that: it is false whenever an API URL is merely *configured*, + * including in dev against a backend that is not running — precisely the case + * where the fallback has to work. + */ +export function publishReport(item: LostItem, trip: Trip): void { + const s = store(); + if (!s) return; + + const next = [notificationFromReport(item, trip), ...readReports()].slice( + 0, + config.demo.handoffLimit, + ); + + try { + s.setItem(config.demo.handoffKey, JSON.stringify(next)); + } catch { + // Quota or a locked-down browser: the demo degrades to the mock list. + return; + } + window.dispatchEvent(new CustomEvent(SAME_TAB_EVENT)); +} + +/** + * Calls back with the full report list whenever it changes — in this tab and in + * any other tab on this origin. Returns the unsubscribe. + */ +export function subscribeReports(onChange: (reports: StaffNotification[]) => void): () => void { + if (typeof window === 'undefined') return () => {}; + + const handleStorage = (event: StorageEvent) => { + if (event.key !== null && event.key !== config.demo.handoffKey) return; + onChange(readReports()); + }; + const handleSameTab = () => onChange(readReports()); + + window.addEventListener('storage', handleStorage); + window.addEventListener(SAME_TAB_EVENT, handleSameTab); + + return () => { + window.removeEventListener('storage', handleStorage); + window.removeEventListener(SAME_TAB_EVENT, handleSameTab); + }; +} diff --git a/frontend/lib/mock-data.ts b/frontend/lib/mock-data.ts index 1e1f671..84a6e85 100644 --- a/frontend/lib/mock-data.ts +++ b/frontend/lib/mock-data.ts @@ -4,6 +4,7 @@ */ import { tenant } from '@/lib/tenant'; +import { UI_LABELS } from './labels'; import type { Trip, LostItem, @@ -319,6 +320,37 @@ export const mockVehicle: Vehicle = { // Staff Notifications Mock Data // ============================================================================ +/** + * The report the crew view stages for a visitor who opens /staff on its own, + * so the arrival moment — the thing this product is about — is visible without + * a second device. A real report handed over from the passenger view takes + * precedence over it (see lib/demo-bus.ts and app/staff/page.tsx). + * + * Built fresh per call so its timestamp reads as "gerade eben", and derived + * from mockActiveTrip so the seat and route cannot drift from the trip the + * passenger view actually shows. + */ +export function createDemoIncomingNotification(): StaffNotification { + const seat = `${UI_LABELS.trip.car} ${mockActiveTrip.car}, ${UI_LABELS.trip.seat} ${mockActiveTrip.seat}`; + return { + id: `notif-demo-${Date.now()}`, + lostItemId: 'lost-demo', + staffId: mockStaff.id, + vehicleId: mockActiveTrip.vehicle.id, + status: 'pending', + message: 'Schwarze Laptop-Tasche', + priority: 'urgent', + location: seat, + category: 'bags', + createdAt: new Date().toISOString(), + passengerInfo: { + tripRoute: `${mockActiveTrip.origin.name} → ${mockActiveTrip.destination.name}`, + tripTime: formatTime(mockActiveTrip.departureTime), + seatInfo: seat, + }, + }; +} + export const mockStaffNotifications: StaffNotification[] = [ { id: 'notif-001',