From 2d79254c335352fc8e3e80d1bdeb8da2d53c7370 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 17 Jun 2026 11:05:35 +0200 Subject: [PATCH] feat(pdf): add legs to pdf export --- client/src/components/PDF/TripPDF.test.ts | 26 +++++++++++++++++++++++ client/src/components/PDF/TripPDF.tsx | 26 +++++++++++++++++------ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index b01868c2..5646e482 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -84,6 +84,22 @@ const transportReservation = { metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), } as any +const multiLegFlight = { + id: 401, + title: 'Flight to Tokyo', + type: 'flight', + day_id: 10, + reservation_time: '2025-06-01T08:00:00', + confirmation_number: 'XYZ789', + metadata: JSON.stringify({ + legs: [ + { from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' }, + { from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' }, + ], + departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1', + }), +} as any + const richArgs = { trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any, days: [dayWithPlaces], @@ -196,6 +212,16 @@ describe('downloadTripPDF', () => { const iframe = getIframe() expect(iframe!.srcdoc).toContain('Flight to Rome') expect(iframe!.srcdoc).toContain('ABC123') + // Single-leg flight keeps its full-route subtitle. + expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO') + }) + + it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => { + await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] }) + const iframe = getIframe() + // One subtitle line per leg, each with its own flight number and segment route. + expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER') + expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND') }) it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => { diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 77ebf78c..ad34a70c 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -6,6 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' import { splitReservationDateTime } from '../../utils/formatters' +import { getFlightLegs } from '../../utils/flightLegs' function renderLucideIcon(icon:LucideIcon, props = {}) { if (!_renderToStaticMarkup) return '' @@ -215,17 +216,30 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const icon = reservationIconSvg(r.type) const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6' let subtitle = '' + // Flights render one subtitle line per leg (see below); everything else is a single line. + let subtitleLines: string[] = [] if (r.type === 'flight') { - // Full route over all waypoints (FRA → BER → HND), falling back to the - // flat metadata pair for legacy single-leg flights without endpoints. - const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name) - const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '') - subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ') + const legs = getFlightLegs(r) + if (legs.length > 1) { + // Multi-leg: one line per leg so every flight number + segment route is shown. + subtitleLines = legs.map(l => + [l.airline, l.flight_number, + (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : ''] + .filter(Boolean).join(' · ')) + .filter(Boolean) + } else { + // Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the + // flat metadata pair for legacy single-leg flights without endpoints. + const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name) + const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '') + subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ') + } } else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ') else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ') else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') + if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle] const locationLine = r.location || meta.location || '' const phase = pdfGetSpanPhase(r, day.id) const spanLabel = pdfGetSpanLabel(r, phase) @@ -238,7 +252,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${icon}
${titleHtml}${time ? ` ${time}` : ''}
- ${subtitle ? `
${escHtml(subtitle)}
` : ''} + ${subtitleLines.filter(Boolean).map(s => `
${escHtml(s)}
`).join('')} ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''}