From fa9206e6e4b05f4a3dfdcb947465d7dce5d40b32 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:37:13 +0200 Subject: [PATCH] fix(demo): tell the passenger what the crew actually answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 651ce50 carried the report to the crew view; the answer never came back. The passenger app fired "Personal sucht aktiv nach Ihrem Gegenstand" five seconds after every report and said nothing else — so when the crew pressed "Nicht gefunden", the passenger screen was not merely silent, it was claiming a search that had already been called off. The bus now runs both ways. The crew's answer is written onto the notification it answers — one key, one shape, no second type and no second copy of the same fact to drift — and the passenger view announces it: found as a success toast, not found as an honest one that names the Fundbüro. Answers already in storage seed the seen set on mount, so a reload does not replay old news, and the scripted "searching" beat stands down when an answer is already in, since with two devices the crew can answer inside those five seconds. `publishResponse` ignores any id that is not in the handover, which is what keeps it off the staged cold-open notification (nobody reported that one) and off anything the backend owns. Falling out of it: answering a handed-over report now survives a reload of /staff, which it did not before — the answer lived only in component state. The three passenger-facing strings moved to lib/labels.ts, where the rest of the UI text lives; "Personal sucht aktiv…" had been a literal in the page. Verified in a browser against the dev server, no backend running: reported three items on /, answered them from /staff in a second tab, and watched the passenger tab receive "Gefunden! Das Personal hat Ihren Gegenstand." and "Personal konnte den Gegenstand nicht finden — Ihre Meldung geht ans Fundbüro." for the matching reports. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014MT1aaWfJeDUZpTxZ3DfHg --- frontend/app/page.tsx | 36 ++++++++++++-- frontend/app/staff/page.tsx | 8 +++- frontend/lib/__tests__/demo-bus.test.ts | 41 +++++++++++++++- frontend/lib/demo-bus.ts | 63 ++++++++++++++++++++++++- frontend/lib/labels.ts | 3 ++ 5 files changed, 144 insertions(+), 7 deletions(-) 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...',