diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 070f925..c114e49 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { StatusBar } from '@/components/ui/StatusBar'; import { Header } from '@/components/passenger/Header'; import { TripCard } from '@/components/passenger/TripCard'; @@ -10,8 +10,9 @@ import { Toast } from '@/components/ui/Toast'; import { mockUser, formatRelativeTime } from '@/lib/mock-data'; import { config } from '@/lib/config'; import { useCurrentTrip, useTrips } from '@/lib/hooks'; +import { readReports, subscribeReports } from '@/lib/demo-bus'; import { UI_LABELS } from '@/lib/labels'; -import type { Trip, LostItem } from '@/lib/types'; +import type { Trip, LostItem, StaffNotification } from '@/lib/types'; // Tab content components function PlanenTab() { @@ -178,6 +179,8 @@ export default function PassengerApp() { type: 'success' | 'error' | 'info'; } | null>(null); const [activeTab, setActiveTab] = useState('reisen'); + // Answers already announced, so a re-read never repeats a toast. + const announcedIds = useRef>(new Set()); const { data: currentTrip, isLoading: isLoadingCurrentTrip } = useCurrentTrip(); const { data: recentTripsData, isLoading: isLoadingTrips } = useTrips(); const recentTrips = recentTripsData ?? []; @@ -195,15 +198,40 @@ export default function PassengerApp() { type: 'success', }); - // Demo: Simulate staff searching + // The crew is looking — the plausible next beat while nobody has answered + // yet. It must never talk over a real answer, so it stands down once one + // is in: with two devices on the demo, the crew can answer inside these + // few seconds. setTimeout(() => { + const answered = readReports().find((n) => n.lostItemId === item.id)?.respondedAt; + if (answered) return; setToast({ - message: 'Personal sucht aktiv nach Ihrem Gegenstand', + message: UI_LABELS.lostItem.staffSearching, type: 'info', }); }, config.timing.demoNotificationDelay); }, []); + // What the crew answered, told to the passenger — the last hop of the flow + // this demo exists to show. Answers already sitting in storage are history, + // not news: they seed the seen set on mount so a reload stays quiet. + useEffect(() => { + const announce = (reports: StaffNotification[], onMount: boolean) => { + const answered = reports.filter((n) => n.respondedAt && !announcedIds.current.has(n.id)); + answered.forEach((n) => announcedIds.current.add(n.id)); + if (onMount || answered.length === 0) return; + + const found = answered[0].status === 'found'; + setToast({ + message: found ? UI_LABELS.lostItem.itemFound : UI_LABELS.lostItem.itemNotFound, + type: found ? 'success' : 'info', + }); + }; + + announce(readReports(), true); + return subscribeReports((reports) => announce(reports, false)); + }, []); + const handleCloseModal = useCallback(() => { setShowLostModal(false); setSelectedTrip(null); diff --git a/frontend/app/staff/page.tsx b/frontend/app/staff/page.tsx index 949fbb8..736b97e 100644 --- a/frontend/app/staff/page.tsx +++ b/frontend/app/staff/page.tsx @@ -8,7 +8,7 @@ import type { StaffNotification, NotificationStatus } from '@/lib/types'; 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 { publishResponse, 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. */ @@ -91,6 +91,12 @@ export default function StaffPage() { : n, ), ); + + // Tell the passenger. Ignored for the staged notification, which nobody + // reported and which is therefore not in the handover — see demo-bus. + if (status === 'found' || status === 'not_found') { + publishResponse(notificationId, status, notes); + } return; } diff --git a/frontend/lib/__tests__/demo-bus.test.ts b/frontend/lib/__tests__/demo-bus.test.ts index a9b3c99..431317d 100644 --- a/frontend/lib/__tests__/demo-bus.test.ts +++ b/frontend/lib/__tests__/demo-bus.test.ts @@ -5,11 +5,14 @@ * parser (storage is shared, long-lived, and therefore untrusted input). */ -import { notificationFromReport, parseReports } from '../demo-bus'; +import { answerReport, notificationFromReport, parseReports } from '../demo-bus'; import { config } from '../config'; import { mockActiveTrip, mockTrips } from '../mock-data'; import type { LostItem, StaffNotification, Trip } from '../types'; +/** Fixed so an answer's timestamp is asserted, not merely present. */ +const STAMP = '2026-09-01T09:00:00.000Z'; + function report(overrides: Partial = {}): LostItem { return { id: 'lost-test-1', @@ -102,3 +105,39 @@ describe('parseReports', () => { expect(parsed).toEqual([good]); }); }); + +describe('answerReport', () => { + const reports = [ + notificationFromReport(report({ id: 'lost-a' }), mockActiveTrip), + notificationFromReport(report({ id: 'lost-b' }), mockActiveTrip), + ]; + + it('writes the crew’s answer onto the notification it answers', () => { + const answered = answerReport(reports, 'notif-lost-a', 'found', 'lag in der Ablage', STAMP); + + expect(answered[0]).toMatchObject({ + id: 'notif-lost-a', + status: 'found', + respondedAt: STAMP, + response: { notes: 'lag in der Ablage', foundItem: true }, + }); + }); + + it('leaves every other report untouched', () => { + const answered = answerReport(reports, 'notif-lost-a', 'not_found', undefined, STAMP); + + expect(answered[1]).toEqual(reports[1]); + expect(answered[1].respondedAt).toBeUndefined(); + }); + + it('records a not-found answer as such, without inventing a note', () => { + const [first] = answerReport(reports, 'notif-lost-a', 'not_found', undefined, STAMP); + + expect(first.status).toBe('not_found'); + expect(first.response).toBeUndefined(); + }); + + it('changes nothing when the id is not one of ours', () => { + expect(answerReport(reports, 'notif-someone-else', 'found', undefined, STAMP)).toEqual(reports); + }); +}); diff --git a/frontend/lib/demo-bus.ts b/frontend/lib/demo-bus.ts index a694029..ec6010a 100644 --- a/frontend/lib/demo-bus.ts +++ b/frontend/lib/demo-bus.ts @@ -13,11 +13,21 @@ * 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`. + * + * It carries the crew's answer back the same way. One key, one shape: the + * answer is written onto the notification it answers, so there is no second + * type and no second copy of the same fact to drift. */ import { config } from './config'; import { ITEM_LOCATION_CONFIG } from './types'; -import type { LostItem, StaffNotification, NotificationPriority, Trip } from './types'; +import type { + LostItem, + StaffNotification, + NotificationPriority, + NotificationStatus, + Trip, +} from './types'; import { UI_LABELS } from './labels'; import { mockStaff } from './mock-data'; @@ -142,6 +152,57 @@ export function publishReport(item: LostItem, trip: Trip): void { window.dispatchEvent(new CustomEvent(SAME_TAB_EVENT)); } +/** + * The crew's answer, written onto the notification it answers. Pure, so the + * part that can silently drop an answer is testable without a browser. + */ +export function answerReport( + reports: StaffNotification[], + notificationId: string, + status: Extract, + notes?: string, + respondedAt: string = new Date().toISOString(), +): StaffNotification[] { + return reports.map((n) => + n.id === notificationId + ? { + ...n, + status, + respondedAt, + response: notes ? { notes, foundItem: status === 'found' } : undefined, + } + : n, + ); +} + +/** + * Sends the crew's answer back to the passenger view. Same rule as + * `publishReport`: only for a notification this browser handed over, never for + * one the backend owns — there the answer travels back the way it came. + */ +export function publishResponse( + notificationId: string, + status: Extract, + notes?: string, +): void { + const s = store(); + if (!s) return; + + const reports = readReports(); + if (!reports.some((n) => n.id === notificationId)) return; + + try { + s.setItem( + config.demo.handoffKey, + JSON.stringify(answerReport(reports, notificationId, status, notes)), + ); + } catch { + // Quota or a locked-down browser: the crew view keeps its local answer. + 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. diff --git a/frontend/lib/labels.ts b/frontend/lib/labels.ts index befc402..07f27c1 100644 --- a/frontend/lib/labels.ts +++ b/frontend/lib/labels.ts @@ -142,6 +142,9 @@ export const UI_LABELS = { notifyDriver: 'Personal sofort benachrichtigen', driverNotified: 'Personal benachrichtigt!', driverNotifiedMessage: 'Personal wurde sofort benachrichtigt!', + staffSearching: 'Personal sucht aktiv nach Ihrem Gegenstand', + itemFound: 'Gefunden! Das Personal hat Ihren Gegenstand.', + itemNotFound: 'Personal konnte den Gegenstand nicht finden — Ihre Meldung geht ans Fundbüro.', urgent: 'Dringend', actFast: 'Schnell handeln!', sending: 'Wird gesendet...',