Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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() {
Expand Down Expand Up @@ -178,6 +179,8 @@ export default function PassengerApp() {
type: 'success' | 'error' | 'info';
} | null>(null);
const [activeTab, setActiveTab] = useState<NavTab>('reisen');
// Answers already announced, so a re-read never repeats a toast.
const announcedIds = useRef<Set<string>>(new Set());
const { data: currentTrip, isLoading: isLoadingCurrentTrip } = useCurrentTrip();
const { data: recentTripsData, isLoading: isLoadingTrips } = useTrips();
const recentTrips = recentTripsData ?? [];
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion frontend/app/staff/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}

Expand Down
41 changes: 40 additions & 1 deletion frontend/lib/__tests__/demo-bus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): LostItem {
return {
id: 'lost-test-1',
Expand Down Expand Up @@ -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);
});
});
63 changes: 62 additions & 1 deletion frontend/lib/demo-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<NotificationStatus, 'found' | 'not_found'>,
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<NotificationStatus, 'found' | 'not_found'>,
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.
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...',
Expand Down
Loading