From 5367d24f9f6c2c233c79bdf4aa4f857b198817ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 19 Jun 2026 17:56:41 +0200 Subject: [PATCH] fix(pdf): show photos for OSM places in the trip PDF (#1130) The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI. --- client/src/components/PDF/TripPDF.test.ts | 22 ++++++++++++++++++ client/src/components/PDF/TripPDF.tsx | 28 +++++++++++++++-------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 5646e482..d90e81b2 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -323,6 +323,28 @@ describe('downloadTripPDF', () => { expect(photoCalled).toBe(true) }) + it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => { + let fetchedId: string | null = null + server.use( + http.get('/api/maps/place-photo/:placeId', ({ params }) => { + fetchedId = params.placeId as string + return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' }) + }), + ) + // The assignment projection drops osm_id; the full place in `places` carries it. + const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 } + const args = { + ...richArgs, + places: [osmPlace], + assignments: { + '10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }], + } as any, + } + await downloadTripPDF(args) + // osm_id is used as the photo key (not the coords fallback), proving the pool lookup works. + expect(fetchedId).toBe('node/240109189') + }) + it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => { const args = { ...minimalArgs, diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index ad34a70c..66422d32 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -97,21 +97,29 @@ function dayCost(assignments, dayId, locale) { return total > 0 ? `${total.toLocaleString(locale)} EUR` : null } -// Pre-fetch Google Place photos for all assigned places -async function fetchPlacePhotos(assignments: AssignmentsMap) { +// Pre-fetch place photos for all assigned places. +// Assignment places are a server-side projection that drops osm_id, so we recover +// the full place from the trip's places pool and key the photo off the same id the +// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only +// places fell back to category icons in the PDF even though they show photos in-app. +async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) { const photoMap = {} // placeId → photoUrl + // The assignment projection drops osm_id, so recover it from the full places pool. + const osmById = new Map((places || []).map(p => [p.id, p.osm_id])) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] - // Assignment places are a server-side projection that omits osm_id, so photo - // pre-fetch keys off the google_place_id that the projection does carry. - const toFetch = unique.filter(p => !p.image_url && p.google_place_id) + const toFetch = unique + .map(p => ({ p, osm_id: osmById.get(p.id) })) + .filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null))) await Promise.allSettled( - toFetch.map(async (place) => { + toFetch.map(async ({ p, osm_id }) => { + // Same key the app UI uses: google_place_id || osm_id || coords. + const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}` try { - const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name) - if (data.photoUrl) photoMap[place.id] = data.photoUrl + const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name) + if (data.photoUrl) photoMap[p.id] = data.photoUrl } catch {} }) ) @@ -141,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed const accommodations = await accommodationsApi.list(trip.id); - // Pre-fetch place photos from Google - const photoMap = await fetchPlacePhotos(assignments) + // Pre-fetch place photos (Google, OSM and coords-only places) + const photoMap = await fetchPlacePhotos(assignments, places) const totalAssigned = new Set( Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)