From e6fcbc77899ef09d9dbbea8ba12bcdb3f4587a97 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 17 Jun 2026 10:44:05 +0200 Subject: [PATCH] fix(shared-view): render each leg of multi-leg flights correctly The read-only shared view showed the overall trip start/end airports and the first leg's flight number on every leg of a multi-leg flight. The Day Plan already expands legs (each carries __leg), but the renderer ignored it and read flat top-level metadata; the Bookings tab had the same bug. - Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time - Bookings tab: list each leg via getFlightLegs() - unique React keys for multi-leg rows Closes #1219 --- client/src/pages/SharedTripPage.test.tsx | 75 ++++++++++++++++++++++++ client/src/pages/SharedTripPage.tsx | 21 +++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 5c5b05d1..7b6c9119 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -405,4 +405,79 @@ describe('SharedTripPage', () => { }); }); }); + + describe('FE-PAGE-SHARED-017: Multi-leg flight shows each leg in the Day Plan', () => { + const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null }; + const multiLegFlight = { + id: 9, trip_id: 1, title: 'Flight', type: 'flight', status: 'confirmed', + day_id: 101, end_day_id: 101, + reservation_time: '2026-07-01T08:00:00', reservation_end_time: '2026-07-01T20:00:00', + metadata: JSON.stringify({ + legs: [ + { from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1', dep_day_id: 101, dep_time: '08:00', arr_day_id: 101, arr_time: '09:00' }, + { from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2', dep_day_id: 101, dep_time: '10:00', arr_day_id: 101, arr_time: '20:00' }, + ], + departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1', + }), + }; + + function serveMultiLeg(token: string) { + server.use( + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== token) return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' }, + days: [day], + assignments: {}, + dayNotes: {}, + places: [], + reservations: [multiLegFlight], + accommodations: [], + packing: [], + budget: [], + categories: [], + permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false }, + collab: [], + }); + }), + ); + } + + it('renders each leg with its own route, not the overall start/end', async () => { + serveMultiLeg('multileg-token'); + renderSharedTrip('multileg-token'); + + await waitFor(() => { + expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + }); + + // Expand the day to reveal the timeline + fireEvent.click(screen.getByText('Day One')); + + await waitFor(() => { + expect(screen.getByText(/FRA → BER/)).toBeInTheDocument(); + }); + // Second leg shows its OWN route + flight number (the bug showed the overall route here) + expect(screen.getByText(/BER → HND/)).toBeInTheDocument(); + expect(screen.getByText(/LH2/)).toBeInTheDocument(); + // The overall start→end must NOT appear on any leg + expect(screen.queryByText(/FRA → HND/)).toBeNull(); + }); + + it('lists each leg flight number in the Bookings tab', async () => { + serveMultiLeg('multileg-bookings-token'); + renderSharedTrip('multileg-bookings-token'); + + await waitFor(() => { + expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /bookings/i })); + + await waitFor(() => { + expect(screen.getByText(/LH1/)).toBeInTheDocument(); + }); + expect(screen.getByText(/LH2/)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 49771820..ef262b18 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' import { isDayInAccommodationRange } from '../utils/dayOrder' import { getTransportForDay, getMergedItems } from '../utils/dayMerge' +import { getFlightLegs } from '../utils/flightLegs' import { splitReservationDateTime } from '../utils/formatters' const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } @@ -214,16 +215,24 @@ export default function SharedTripPage() { const TIcon = TRANSPORT_ICONS[r.type] || Ticket const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) const time = splitReservationDateTime(r.reservation_time).time ?? '' + const endTime = splitReservationDateTime(r.reservation_end_time).time ?? '' let sub = '' - if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') + if (r.type === 'flight') { + if (r.__leg) { + // One leg of a multi-leg flight — show this segment's own route/flight number. + sub = [r.__leg.airline, r.__leg.flight_number, (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ') + } else { + sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') + } + } else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ') return ( -
+
-
{r.title}{time ? ` · ${time}` : ''}
+
{r.title}{time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''}
{sub &&
{sub}
}
@@ -284,7 +293,11 @@ export default function SharedTripPage() { {date && {date}} {time && {time}} {r.location && {r.location}} - {meta.airline && {meta.airline} {meta.flight_number || ''}} + {r.type === 'flight' + ? getFlightLegs(r).map((leg, i) => ( + {[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')} + )) + : meta.airline && {meta.airline} {meta.flight_number || ''}} {meta.train_number && {meta.train_number}}