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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
97 changes: 54 additions & 43 deletions frontend/app/staff/page.tsx
Original file line number Diff line number Diff line change
@@ -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<StaffNotification[]>([]);
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<StaffNotification | null>(null);
const [arrivedId, setArrivedId] = useState<string | null>(null);
const seenIds = useRef<Set<string>>(new Set());

const {
data: fetchedNotifications,
Expand All @@ -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) => {
Expand Down Expand Up @@ -188,31 +204,26 @@ 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'}
/>
))
)}
</main>

{/* Incoming Notification Alert */}
{showNewNotification && (
{/* Incoming Notification Alert — the report that just arrived, not a script */}
{arrival && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20 animate-fade-in">
<div className="bg-white rounded-app-lg shadow-xl mx-4 max-w-sm w-full animate-slide-down overflow-hidden">
<div className="bg-gradient-to-r from-brand to-brand-hover text-white p-4 text-center">
<div className="text-4xl mb-2">🚨</div>
<h3 className="text-lg font-semibold">{UI_LABELS.staff.newLostReport}</h3>
</div>
<div className="p-4">
<p className="text-app-base text-app-charcoal font-medium mb-1">
Schwarze Laptop-Tasche
</p>
<p className="text-app-base text-app-charcoal font-medium mb-1">{arrival.message}</p>
<p className="text-app-sm text-app-granite mb-4">
Wagen 7, Platz 45 • Zürich HB → Bern
{[arrival.location, arrival.passengerInfo?.tripRoute].filter(Boolean).join(' • ')}
</p>
<button
onClick={() => setShowNewNotification(false)}
className="btn-app-primary w-full"
>
<button onClick={() => setArrival(null)} className="btn-app-primary w-full">
{UI_LABELS.staff.viewReport}
</button>
</div>
Expand Down
8 changes: 7 additions & 1 deletion frontend/components/passenger/LostItemModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions frontend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/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: { '^@/(.*)$': '<rootDir>/$1' },
transform: {
// The app's tsconfig targets the bundler (module: esnext), which Node
// cannot execute directly — override to commonjs for the test run only.
Expand Down
104 changes: 104 additions & 0 deletions frontend/lib/__tests__/demo-bus.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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]);
});
});
7 changes: 7 additions & 0 deletions frontend/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading