diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 4ad361ae8..6ac7866b6 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -8,7 +8,7 @@ "src/components/Map/MapView.tsx": 1385, "src/components/Map/MapViewGL.test.tsx": 2919, "src/components/Map/MapViewGL.tsx": 2218, - "src/components/PDF/TripPDF.tsx": 1082, + "src/components/PDF/TripPDF.tsx": 1054, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5349, "src/components/Planner/DayPlanSidebar.tsx": 2936, diff --git a/client/src/components/Map/TripRouteOverview.test.tsx b/client/src/components/Map/TripRouteOverview.test.tsx index 3bc881995..4448ebb11 100644 --- a/client/src/components/Map/TripRouteOverview.test.tsx +++ b/client/src/components/Map/TripRouteOverview.test.tsx @@ -100,6 +100,9 @@ describe('TripRouteOverviewPanel', () => { expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted') expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1) + // The short figures read as floors, the trip's and the day's, not as measurements (#2876). + expect(screen.getAllByText('map.overview.distanceAtLeast')).toHaveLength(2) + expect(screen.getByText('140.5 km')).toBeInTheDocument() }) it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => { diff --git a/client/src/components/Map/TripRouteOverview.tsx b/client/src/components/Map/TripRouteOverview.tsx index 4db303931..65161d23d 100644 --- a/client/src/components/Map/TripRouteOverview.tsx +++ b/client/src/components/Map/TripRouteOverview.tsx @@ -2,7 +2,7 @@ import { AlertTriangle, Route } from 'lucide-react' import { useTranslation } from '../../i18n' import { MapTogglePill } from './MapTogglePill' import { routeModeIcon } from '../Planner/routeModes' -import { formatDistance } from '../../utils/units' +import { routeDistanceLabel } from './tripRouteLabels' import type { TripRouteOverview as Overview } from './useTripRouteOverview' import type { DistanceUnit } from '../../types' @@ -65,7 +65,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect {/* Same tier as the label beside it: the two read as one line rather than as a heading with a number stuck under it. */} - {formatDistance(overview.totalDistance / 1000, unit)} + {routeDistanceLabel(overview.totalDistance, unrouted, unit, t)} {/* Still routing: the number is a partial sum, and saying so beats a total that silently grows while you read it. */} {overview.loading && {' '}…} @@ -112,7 +112,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect /> )} - {formatDistance(day.distance / 1000, unit)} + {routeDistanceLabel(day.distance, dayUnrouted, unit, t)} ) diff --git a/client/src/components/Map/tripRouteGeometry.test.ts b/client/src/components/Map/tripRouteGeometry.test.ts index a87b91366..8bfb54e4c 100644 --- a/client/src/components/Map/tripRouteGeometry.test.ts +++ b/client/src/components/Map/tripRouteGeometry.test.ts @@ -78,31 +78,61 @@ describe('tripRouteGeometry', () => { expect(calculateRouteWithLegs).not.toHaveBeenCalled() }) - it('FE-MAP-TRG-005: the deadline gives back what answered and leaves the rest as straight lines', async () => { - neverAnswers() + // The PDF used to route under an 8 s deadline: with a second between two requests that + // is about seven legs, so a month-long trip printed most of its days as straight lines + // and a total that looked final, and every export routed a few more days (#2876). + it('FE-MAP-TRG-005: a long trip is routed to its last leg, and says how far it has got', async () => { + const days = Array.from({ length: 12 }, (_, i) => buildDay({ id: i + 1, day_number: i + 1 })) + const assignments: AssignmentsMap = Object.fromEntries(days.map(day => [ + String(day.id), + [at(48 + day.id / 10, 2.35, 0, day.id), at(48 + day.id / 10, 2.45, 1, day.id)], + ])) vi.useFakeTimers() try { - const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 8000 }) - await vi.advanceTimersByTimeAsync(8100) + vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => { + setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100) + })) + const onProgress = vi.fn<(done: number, total: number) => void>() + const pending = routeTrip({ ...input, days, assignments }, { profile: 'driving', tripId: 7, onProgress }) + await vi.advanceTimersByTimeAsync(12 * 1200) const summary = await pending - // The shape of the trip survives... - expect(summary.lines).toHaveLength(2) - expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]]) - // ...and nothing that never answered is counted. - expect(summary.totalDistance).toBe(0) + expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12) + expect(summary.unroutedLegs).toBe(0) + expect(summary.totalDistance).toBe(120000) + expect(onProgress.mock.calls[0]).toEqual([0, 12]) + expect(onProgress.mock.calls.map(([done]) => done)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + expect(onProgress.mock.calls.every(([, total]) => total === 12)).toBe(true) } finally { vi.useRealTimers() } }) - it('FE-MAP-TRG-006: a caller\'s own abort cuts the routing short too', async () => { + it('FE-MAP-TRG-006: a caller's own abort cuts the routing short, and what never answered is counted', async () => { neverAnswers() const controller = new AbortController() - const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 60_000, signal: controller.signal }) + const pending = routeTrip(input, { profile: 'driving', tripId: 7, signal: controller.signal }) controller.abort() - await expect(pending).resolves.toMatchObject({ totalDistance: 0 }) + await expect(pending).resolves.toMatchObject({ totalDistance: 0, unroutedLegs: 2 }) + }) + + it('FE-MAP-TRG-014: a host that never answers costs one leg its road, not the whole round', async () => { + neverAnswers() + vi.useFakeTimers() + try { + const pending = routeTrip(input, { profile: 'driving', tripId: 7 }) + await vi.advanceTimersByTimeAsync(30_000) + expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1100 + 30_000) + const summary = await pending + + expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2) + expect(summary.lines).toHaveLength(2) + expect(summary.unroutedLegs).toBe(2) + } finally { + vi.useRealTimers() + } }) // The public OSRM hosts allow about one request a second. Every leg of every day at diff --git a/client/src/components/Map/tripRouteGeometry.ts b/client/src/components/Map/tripRouteGeometry.ts index 6b98b0419..ba4ea2f8e 100644 --- a/client/src/components/Map/tripRouteGeometry.ts +++ b/client/src/components/Map/tripRouteGeometry.ts @@ -29,7 +29,7 @@ export interface TripOverviewDay { modes: string[] /** * Legs drawn as a straight line because the router gave no road for them: not asked - * yet while the round is running, refused or cut off by the deadline once it is over. + * yet while the round is running, refused, timed out or skipped by the caller once it is over. * Those legs add nothing to `distance`, so a day with any of them reads too short. */ unroutedLegs?: number @@ -56,7 +56,7 @@ export interface TripRouteSummary { interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] } /** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */ -interface TripRouteInputs extends DayRouteInputs { +export interface TripRouteInputs extends DayRouteInputs { /** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */ storedAssignments?: AssignmentsMap viasByDay?: Record @@ -187,6 +187,15 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary { const REQUEST_SPACING_MS = 1100 /** Anything answered faster than this came out of RouteCalculator's cache, not the network. */ const CACHE_HIT_MS = 60 +/** + * The longest one leg may take, its rate-limit retries included. The OSRM request has no + * deadline of its own, and a round that waits for every leg must not hang on a host that + * accepted the connection and never answered. + */ +const LEG_TIMEOUT_MS = 30_000 + +/** How far a routing round has got: legs answered or given up on, out of all it asks. */ +export type TripRouteProgress = (done: number, total: number) => void /** * Ask the router for every leg of every day, one at a time. @@ -206,13 +215,15 @@ const CACHE_HIT_MS = 60 */ export async function routeTripLegs( plan: TripRoutePlanDay[], - { tripId, signal, onAnswer, unit }: { + { tripId, signal, onAnswer, onProgress, unit }: { tripId: number | null signal?: AbortSignal /** The unit a leg bent through vias has its text written again in. */ unit?: DistanceUnit /** Called after each answer, for a caller that wants to draw as they arrive. */ onAnswer?: (routed: Answer[][][]) => void + /** Called once before the first leg and again after every leg, answered or not. */ + onProgress?: TripRouteProgress }, ): Promise { const routed = emptyAnswers(plan) @@ -222,25 +233,34 @@ export async function routeTripLegs( chunks.forEach((chunk, c) => { tasks.push(async () => { if (signal?.aborted) return + const leg = new AbortController() + const abortLeg = () => leg.abort() + const timer = setTimeout(abortLeg, LEG_TIMEOUT_MS) + signal?.addEventListener('abort', abortLeg, { once: true }) try { const answer = await calculateRouteWithRetries( chunk.waypoints, - { signal, profile: chunk.mode, tripId, dayId: day.id }, + { signal: leg.signal, profile: chunk.mode, tripId, dayId: day.id }, ) routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) } onAnswer?.(routed) } catch { - // Refused for good, or aborted: the leg keeps its straight line. + // Refused for good, timed out or aborted: the leg keeps its straight line. + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', abortLeg) } }) }) }) }) + onProgress?.(0, tasks.length) for (let i = 0; i < tasks.length; i++) { if (signal?.aborted) break const startedAt = performance.now() await tasks[i]() + onProgress?.(i + 1, tasks.length) const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal) } @@ -250,30 +270,25 @@ export async function routeTripLegs( /** * The whole thing end to end, for a caller with no need to draw the intermediate state. * - * `timeoutMs` stops waiting rather than stopping the work: legs that answered are kept - * and the rest stay straight lines, which is what makes this usable from an export - * somebody is standing in front of. A month-long trip with a cold cache is a few hundred - * requests against a shared router, and no document is worth that wait. + * Waits for every leg: a cold month-long trip is a few minutes against a shared router, + * which is why `onProgress` exists, and the pacing, the retry limit and the per-leg + * timeout keep that wait finite. A document printed after a deadline looked finished + * while most of its days were straight lines (#2876). The caller's `signal` is the only + * way to stop early; the legs that answered by then are kept, the rest stay straight + * lines and are counted in `unroutedLegs`. */ export async function routeTrip( input: TripRouteInputs, - { profile, tripId, signal, timeoutMs, unit }: { + { profile, tripId, signal, unit, onProgress }: { profile: RouteProfileKey tripId: number | null signal?: AbortSignal - timeoutMs?: number unit?: DistanceUnit + onProgress?: TripRouteProgress }, ): Promise { const plan = planTripRoute(input, profile) if (!plan.length) return summariseTripRoute([]) - const deadline = timeoutMs ? new AbortController() : null - const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null - if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true }) - try { - const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit }) - return summariseTripRoute(assembleTripRoute(plan, routed)) - } finally { - if (timer) clearTimeout(timer) - } + const routed = await routeTripLegs(plan, { tripId, signal, unit, onProgress }) + return summariseTripRoute(assembleTripRoute(plan, routed)) } diff --git a/client/src/components/Map/tripRouteLabels.ts b/client/src/components/Map/tripRouteLabels.ts new file mode 100644 index 000000000..05e324050 --- /dev/null +++ b/client/src/components/Map/tripRouteLabels.ts @@ -0,0 +1,18 @@ +import type { DistanceUnit } from '../../types'; +import { formatDistance } from '../../utils/units'; + +type Translate = (key: string, params?: Record) => string; + +/** + * A day's or the trip's distance as the overview panel and the PDF print it. + * + * A leg the router gave no road for is drawn straight and adds nothing to the sum, so a + * figure with such legs in it is a floor, and a figure made of nothing but such legs is + * no figure at all. "0 m" for a day of driving read as a measurement (#2876). One wording + * for the screen and the document, so the two cannot drift apart again. + */ +export function routeDistanceLabel(meters: number, unroutedLegs: number, unit: DistanceUnit, t: Translate): string { + const distance = formatDistance(meters / 1000, unit); + if (unroutedLegs <= 0) return distance; + return meters > 0 ? t('map.overview.distanceAtLeast', { distance }) : t('map.overview.notRouted'); +} diff --git a/client/src/components/PDF/TripPDF.map.test.ts b/client/src/components/PDF/TripPDF.map.test.ts index 3f7639f5f..946518bb0 100644 --- a/client/src/components/PDF/TripPDF.map.test.ts +++ b/client/src/components/PDF/TripPDF.map.test.ts @@ -20,7 +20,7 @@ vi.mock('../Map/tripRouteGeometry', async (importActual) => { // basemap branch and the outline fallback can be exercised. vi.mock('./tripMapImage', () => ({ renderTripMapImage: vi.fn(async () => null) })) -const { calculateRouteWithLegs } = await import('../Map/RouteCalculator') +const { calculateRouteWithLegs, RoutingRefusedError } = await import('../Map/RouteCalculator') const { renderTripMapImage } = await import('./tripMapImage') const { routeTrip } = await import('../Map/tripRouteGeometry') const { downloadTripPDF } = await import('./TripPDF') @@ -324,4 +324,73 @@ describe('trip route map in the PDF', () => { expect(html).not.toContain('pdf.distanceLabel') }) + // The export used to give the router 8 s: about seven legs at a second each, so a + // month-long trip printed most of its days as straight lines at "0 m" under a total + // that looked final, and every export routed a few more days (#2876). + it('FE-COMP-TRIPPDF-MAP-013: a long trip prints every day on the road, however long the router takes', async () => { + const days = Array.from({ length: 12 }, (_, i) => + ({ id: i + 1, day_number: i + 1, title: null, date: `2025-06-${String(i + 1).padStart(2, '0')}` })) + const assignments = Object.fromEntries(days.map(day => [String(day.id), [ + assign(day.id * 10, day.id, 0, 45 + day.id / 10, 4.8), + assign(day.id * 10 + 1, day.id, 1, 45 + day.id / 10, 4.9), + ]])) + vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => { + setTimeout(() => resolve({ coordinates: [[45, 4.8], [45, 4.9]], distance: 12000, duration: 900, legs: [leg(12000)] }), 100) + })) + vi.useFakeTimers() + try { + let settled = false + const pending = downloadTripPDF({ ...args, days: days as never, assignments: assignments as never }) + .finally(() => { settled = true }) + for (let second = 0; second < 60 && !settled; second++) await vi.advanceTimersByTimeAsync(1000) + await pending + } finally { + vi.useRealTimers() + } + + expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12) + const html = srcdoc() + expect(html).toContain('144 km') + expect(html).not.toContain('map.overview.unrouted') + expect(html).not.toContain('map.overview.notRouted') + }) + + it('FE-COMP-TRIPPDF-MAP-014: a leg the router refused is marked, and the total says it is short', async () => { + vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new RoutingRefusedError(400, null)) + await downloadTripPDF(args) + const html = srcdoc() + const legend = html.slice(html.indexOf('class="trip-map-legend"')) + + // Day 1 drove nowhere the router would say: no "0 m", but that it was not routed... + expect(legend).toContain('map.overview.notRouted') + expect(legend).not.toContain('>0 m<') + // ...and the 12 km of day 2 are a floor for the trip, said as such. + expect(html).toContain('pdf.distanceLabel: map.overview.distanceAtLeast') + expect(html).toContain('
map.overview.unrouted
') + }) + + it('FE-COMP-TRIPPDF-MAP-015: routes what the planner map routes, and reports its progress to the caller', async () => { + const viasByDay = { 1: [{ id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.88, lng: 2.5, created_at: '' }] } + const tourStageStops = new Map() + const controller = new AbortController() + const onProgress = vi.fn<(done: number, total: number) => void>() + vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({ + coordinates: points.map(p => [p.lat, p.lng] as [number, number]), + distance: 0, duration: 0, + legs: points.slice(1).map(() => leg(12000)), + })) + await downloadTripPDF({ + ...args, + distanceUnit: 'imperial', + route: { viasByDay, tourStageStops, signal: controller.signal, onProgress }, + }) + + const [input, opts] = vi.mocked(routeTrip).mock.calls[0] + expect(input).toMatchObject({ viasByDay, tourStageStops, storedAssignments: args.assignments }) + expect(opts).toMatchObject({ unit: 'imperial', signal: controller.signal, onProgress }) + // The road picked in the road trip bends day 1 here as it does on the planner map. + expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0].map(p => [p.lat, p.lng])) + .toEqual([[48.86, 2.35], [48.88, 2.5], [48.90, 2.42]]) + expect(onProgress).toHaveBeenLastCalledWith(2, 2) + }) }) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 4ce2abec4..a5ac9fcb0 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -16,10 +16,8 @@ import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import { projectDayItinerary } from '../Map/dayTourProjection' import { planCosts } from '../Planner/planCosts' -import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry' -import { buildTripMapSvg } from './tripMapSvg' -import { renderTripMapImage } from './tripMapImage' -import { formatDistance } from '../../utils/units' +import { routeDistanceLabel } from '../Map/tripRouteLabels' +import { drawTripRoute, type TripPdfRouteOptions } from './tripPdfRoute' import { fetchExchangeRates } from '../../hooks/useExchangeRates' import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs' import { isServiceStopType } from '../Roadtrip/roadtripModel' @@ -201,6 +199,8 @@ interface downloadTripPDFProps { costsEnabled?: boolean /** Prints only this member's plan (#2168): the stops and bookings that name them, and the ones that name nobody. */ onlyUserId?: number + /** The planner's road trip vias and tour stages, and the routing progress and stop (#2876). */ + route?: TripPdfRouteOptions } /** @@ -221,7 +221,7 @@ function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean) // `assignments` is normalised here once, to the plan's own list; every read below // (and fetchPlacePhotos) relies on it being an object. -export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId }: downloadTripPDFProps) { +export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId, route }: downloadTripPDFProps) { const { assignments: stored, reservations } = onlyUserId != null ? onlyMyPlan(allStored, allReservations, onlyUserId) : { assignments: allStored, reservations: allReservations } @@ -252,56 +252,25 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor // just means no extra sections, the core export is untouched. const pluginSections = await pluginsApi.pdfSections(trip.id).then(r => r.sections || []).catch(() => []) - // The trip's route as one map (#1736), drawn from the same builder the planner map - // uses so the document and the screen agree. Fail-safe and time-boxed: whatever the - // router answered inside the budget is drawn, the rest stay straight lines, and any - // failure at all simply means no map rather than no PDF. const unit: DistanceUnit = (_distanceUnit || useSettingsStore.getState().settings.distance_unit) === 'imperial' ? 'imperial' : 'metric' - let tripRoute: TripRouteSummary | null = null const toursEnabled = useAddonStore.getState().isEnabled('tours') const hasInvalidTour = toursEnabled && Object.values(assignments).some(list => projectDayItinerary(list || [], true, places).some(item => item.kind === 'tour' && !item.valid)) - try { - tripRoute = await routeTrip( - { - days: sorted, - assignments, - reservations, - accommodations: accommodationList, - optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation, - toursEnabled, - places, - }, - { profile: 'driving', tripId: trip.id, timeoutMs: 8000 }, - ) - } catch (err) { - // Logged rather than swallowed: a map that is silently absent looks exactly like a - // trip that has no route, and the two need different answers from whoever is - // looking. The export itself carries on — the itinerary matters more than the map. - console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err) - } - // A real basemap first — at city scale the bundled outlines are a country-sized - // blank, and only streets carry context that small. The outline map is what is left - // when there is no WebGL, no network, or a style that will not load. - const mapFrame = { width: 720, height: 420, formatDistance: (km: number) => formatDistance(km, unit) } - const tripMapSvg = tripRoute - ? (await renderTripMapImage(tripRoute.days, { - ...mapFrame, - style: useSettingsStore.getState().settings.maplibre_style, - })) ?? buildTripMapSvg(tripRoute.days, mapFrame) - : null - // The other way to end up mapless, and the one that is not a failure: nothing in the - // trip routed. Only worth saying when there were stops to route — a trip nobody has - // planned yet is meant to print without a map. - if (tripRoute && !tripMapSvg && Object.values(assignments).some(list => list?.length)) { - console.warn( - `[tripPdfMap] no map drawn: ${tripRoute.days.length} of ${sorted.length} day(s) produced a route.` - + ' A day needs two located stops, or one located stop with an accommodation either side of it.', - ) - } + const { route: tripRoute, mapSvg: tripMapSvg } = await drawTripRoute({ + days: sorted, + assignments, + storedAssignments: allStored, + reservations, + accommodations: accommodationList, + optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation, + toursEnabled, + places, + }, { tripId: trip.id, unit, options: route }) + // Legs left straight, refused or skipped, make every figure below a floor; each says so. + const unroutedLegs = tripRoute?.unroutedLegs ?? 0 const totalDistanceLabel = tripRoute && tripRoute.totalDistance > 0 - ? formatDistance(tripRoute.totalDistance / 1000, unit) + ? routeDistanceLabel(tripRoute.totalDistance, unroutedLegs, unit, tr) : null // Each day named and coloured exactly as the legend on the planner map, so the two // read as the same picture. Days that routed to nothing are already out of `days`. @@ -311,12 +280,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor ${escHtml(tr('pdf.mapTitle'))} ${totalDistanceLabel ? `${escHtml(tr('pdf.distanceLabel'))}: ${escHtml(totalDistanceLabel)}` : ''} + ${unroutedLegs ? `
${escHtml(tr('map.overview.unrouted', { count: unroutedLegs }))}
` : ''} ${tripMapSvg}
${tripRoute.days.filter(d => d.lines.length).map(d => ` ${escHtml(d.title || tr('dayplan.dayN', { n: d.dayNumber }))} - ${escHtml(formatDistance(d.distance / 1000, unit))} + ${escHtml(routeDistanceLabel(d.distance, d.unroutedLegs ?? 0, unit, tr))} `).join('')}
${escHtml(tr('pdf.mapCredit'))}
@@ -744,6 +714,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor .trip-map-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; } .trip-map-title { font-size: 11px; font-weight: 600; letter-spacing: 1.4px; text-transform: uppercase; color: #64748b; } .trip-map-total { font-size: 12px; font-weight: 600; color: #334155; } + .trip-map-note { font-size: 9px; font-weight: 600; margin: -4px 0 10px; } .trip-map-svg { width: 100%; height: auto; border-radius: 8px; border: 1px solid #e2e8f0; display: block; } .trip-map-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; } .trip-map-leg { display: flex; align-items: center; gap: 6px; font-size: 9px; color: #475569; } diff --git a/client/src/components/PDF/tripPdfRoute.ts b/client/src/components/PDF/tripPdfRoute.ts new file mode 100644 index 000000000..f1a80ba46 --- /dev/null +++ b/client/src/components/PDF/tripPdfRoute.ts @@ -0,0 +1,72 @@ +import { useSettingsStore } from '../../store/settingsStore'; +import type { DistanceUnit } from '../../types'; +import type { ViasByDay } from '../../utils/dayRouteVias'; +import { formatDistance } from '../../utils/units'; +import type { TourStageStops } from '../Map/dayTourProjection'; +import { + routeTrip, + type TripRouteInputs, + type TripRouteProgress, + type TripRouteSummary, +} from '../Map/tripRouteGeometry'; +import { renderTripMapImage } from './tripMapImage'; +import { buildTripMapSvg } from './tripMapSvg'; + +/** What the planner knows about the trip's route beyond the plan, and how it follows the routing. */ +export interface TripPdfRouteOptions { + /** The roads picked in the road trip (#2580), so the print bends where the planner map does. */ + viasByDay?: ViasByDay; + /** Where the stages of the trip's multi-day tours run. */ + tourStageStops?: TourStageStops; + /** Stops routing early: the legs asked by then are printed, the rest stay straight and are marked. */ + signal?: AbortSignal; + onProgress?: TripRouteProgress; +} + +interface TripPdfRoute { + route: TripRouteSummary | null; + mapSvg: string | null; +} + +const MAP_FRAME = { width: 720, height: 420 }; + +/** + * The trip's route as one map (#1736), from the builder the planner overview uses and + * fed what the overview is fed, so the document and the screen show the same roads. + * + * Every leg is waited for: a deadline here printed a month-long trip with most of its + * days as straight lines and a total that looked final (#2876). Fail-safe all the same: + * a routing failure means no map rather than no PDF. + */ +export async function drawTripRoute( + input: Omit, + { tripId, unit, options = {} }: { tripId: number; unit: DistanceUnit; options?: TripPdfRouteOptions } +): Promise { + const route = await routeTrip( + { ...input, viasByDay: options.viasByDay, tourStageStops: options.tourStageStops }, + { profile: 'driving', tripId, unit, signal: options.signal, onProgress: options.onProgress } + ).catch((err: unknown) => { + // Logged rather than swallowed: a map that is silently absent looks exactly like a + // trip that has no route, and the two need different answers from whoever is + // looking. The export itself carries on — the itinerary matters more than the map. + console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err); + return null; + }); + if (!route) return { route: null, mapSvg: null }; + // A real basemap first — at city scale the bundled outlines are a country-sized + // blank, and only streets carry context that small. The outline map is what is left + // when there is no WebGL, no network, or a style that will not load. + const frame = { ...MAP_FRAME, formatDistance: (km: number) => formatDistance(km, unit) }; + const style = useSettingsStore.getState().settings.maplibre_style; + const mapSvg = (await renderTripMapImage(route.days, { ...frame, style })) ?? buildTripMapSvg(route.days, frame); + // The other way to end up mapless, and the one that is not a failure: nothing in the + // trip routed. Only worth saying when there were stops to route — a trip nobody has + // planned yet is meant to print without a map. + if (!mapSvg && Object.values(input.assignments).some((list) => list?.length)) { + console.warn( + `[tripPdfMap] no map drawn: ${route.days.length} of ${input.days.length} day(s) produced a route.` + + ' A day needs two located stops, or one located stop with an accommodation either side of it.' + ); + } + return { route, mapSvg }; +} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 77f15210a..cb2af0d17 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1455,7 +1455,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP days={days} places={places} categories={categories} - assignments={assignments} + assignments={assignments} roadtripViasByDay={props.roadtripViasByDay} tourStageStops={props.tourStageStops} reservations={reservations} allConnectionsShown={allConnectionsShown} onToggleAllConnections={onToggleAllConnections} diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index ce86daf7b..e2d6278e4 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -10,6 +10,8 @@ import { isRoutableReservation } from '../../utils/reservationRoutes' import type { DayAddControls } from '../../utils/dayAdd' import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' +import type { TourStageStops } from '../Map/dayTourProjection' +import type { ViasByDay } from '../../utils/dayRouteVias' interface DayPlanSidebarToolbarProps { tripId: number @@ -19,6 +21,9 @@ interface DayPlanSidebarToolbarProps { categories: Category[] assignments: AssignmentsMap reservations: Reservation[] + /** Handed on to the export, so the PDF's route map bends where the planner's does. */ + roadtripViasByDay?: ViasByDay + tourStageStops?: TourStageStops allConnectionsShown?: boolean onToggleAllConnections?: () => void dayNotes: Record @@ -50,7 +55,7 @@ interface DayPlanSidebarToolbarProps { } export function DayPlanSidebarToolbar({ - tripId, trip, days, places, categories, assignments, reservations, dayNotes, + tripId, trip, days, places, categories, assignments, reservations, dayNotes, roadtripViasByDay, tourStageStops, allConnectionsShown = false, onToggleAllConnections, t, locale, toast, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, @@ -95,6 +100,8 @@ export function DayPlanSidebarToolbar({ assignments={assignments} reservations={reservations} dayNotes={dayNotes} + roadtripViasByDay={roadtripViasByDay} + tourStageStops={tourStageStops} t={t} locale={locale} toast={toast} diff --git a/client/src/components/Planner/TripExportModal.tsx b/client/src/components/Planner/TripExportModal.tsx index a280d40c0..40a25113a 100644 --- a/client/src/components/Planner/TripExportModal.tsx +++ b/client/src/components/Planner/TripExportModal.tsx @@ -6,6 +6,8 @@ import { IcsSubscribeModal } from './IcsSubscribeModal' import { SoftPill } from './planParts' import { useToast } from '../shared/Toast' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' +import type { TourStageStops } from '../Map/dayTourProjection' +import type { ViasByDay } from '../../utils/dayRouteVias' import { useTripExport } from './useTripExport' /** @@ -31,6 +33,9 @@ interface TripExportModalProps { assignments: AssignmentsMap reservations: Reservation[] dayNotes: Record + /** The road trip's vias and the tour stages, so the PDF's route map matches the planner's. */ + roadtripViasByDay?: ViasByDay + tourStageStops?: TourStageStops t: (key: string, params?: Record) => string locale: string toast: ReturnType @@ -53,15 +58,15 @@ interface TripExportModalProps { */ export function TripExportModal({ isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes, - t, locale, toast, canManageShare = true, + roadtripViasByDay, tourStageStops, t, locale, toast, canManageShare = true, }: TripExportModalProps) { const [subscribeOpen, setSubscribeOpen] = useState(false) const titleId = useId() const { - fileBase, offerMine, isRunning, anyRunning, exportPdf, downloadIcs, downloadGpx, + fileBase, offerMine, isRunning, anyRunning, exportPdf, pdfRouting, pdfRoutingLabel, skipPdfRouting, downloadIcs, downloadGpx, } = useTripExport({ tripId, - data: { trip, days, places, assignments, categories, reservations, dayNotes }, + data: { trip, days, places, assignments, categories, reservations, dayNotes, viasByDay: roadtripViasByDay, tourStageStops }, t, locale, toast, @@ -94,7 +99,7 @@ export function TripExportModal({ exportPdf()} @@ -103,12 +108,22 @@ export function TripExportModal({ exportPdf(true)} /> )} + {pdfRouting && ( + + )} diff --git a/client/src/components/Planner/useTripExport.test.ts b/client/src/components/Planner/useTripExport.test.ts index 53477e331..bd94c69e7 100644 --- a/client/src/components/Planner/useTripExport.test.ts +++ b/client/src/components/Planner/useTripExport.test.ts @@ -1,4 +1,4 @@ -// FE-PLANNER-TRIPEXPORT-001 to -010: the trip exports behind the desktop export +// FE-PLANNER-TRIPEXPORT-001 to -011: the trip exports behind the desktop export // dialog and the phone's export sheet. import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; @@ -238,4 +238,42 @@ describe('useTripExport', () => { expect(opts.onExported).toHaveBeenCalledTimes(1); expect(result.current.anyRunning).toBe(false); }); + + it('FE-PLANNER-TRIPEXPORT-011: the PDF routes what the planner draws, counts its legs and can print without the rest', async () => { + const pdf = deferred(); + vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise); + const viasByDay = { 7: [] }; + const tourStageStops = new Map(); + const opts = options({ data: { ...options().data, viasByDay, tourStageStops } }); + const { result } = renderHook(() => useTripExport(opts)); + let running!: Promise; + act(() => { + running = result.current.exportPdf(); + }); + await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled()); + const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0]; + expect(route).toMatchObject({ viasByDay, tourStageStops }); + expect(result.current.pdfRouting).toBeNull(); + + act(() => route?.onProgress?.(0, 60)); + act(() => route?.onProgress?.(23, 60)); + expect(result.current.pdfRouting).toEqual({ done: 23, total: 60 }); + expect(result.current.pdfRoutingLabel('pdf')).toBe('dayplan.pdfRouting'); + expect(result.current.pdfRoutingLabel('pdf:mine')).toBeNull(); + + // Skipping stops the routing, not the export: the document still comes out. + act(() => result.current.skipPdfRouting()); + expect(route?.signal?.aborted).toBe(true); + expect(result.current.pdfRouting).toBeNull(); + // The leg that was already on its way still reports; the row does not come back for it. + act(() => route?.onProgress?.(24, 60)); + expect(result.current.pdfRouting).toBeNull(); + expect(result.current.isRunning('pdf')).toBe(true); + await act(async () => { + pdf.resolve(); + await running; + }); + expect(result.current.anyRunning).toBe(false); + expect(opts.onExported).toHaveBeenCalledTimes(1); + }); }); diff --git a/client/src/components/Planner/useTripExport.ts b/client/src/components/Planner/useTripExport.ts index 0b1fabbc0..3033e9d0c 100644 --- a/client/src/components/Planner/useTripExport.ts +++ b/client/src/components/Planner/useTripExport.ts @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'; import { useAuthStore } from '../../store/authStore'; import { useSettingsStore } from '../../store/settingsStore'; import type { AssignmentsMap, Category, Day, DayNote, Place, Reservation, Trip } from '../../types'; import { importChunk } from '../../utils/chunkReload'; +import type { ViasByDay } from '../../utils/dayRouteVias'; +import type { TourStageStops } from '../Map/dayTourProjection'; import { hasPersonalPlan } from '../PDF/pdfScope'; type Translate = (key: string, params?: Record) => string; @@ -21,6 +23,15 @@ export interface TripExportData { categories: Category[]; reservations: Reservation[]; dayNotes: Record; + /** The road trip's vias and the multi-day tour stages, so the PDF map routes what the planner map does. */ + viasByDay?: ViasByDay; + tourStageStops?: TourStageStops; +} + +/** How far the PDF's route map has got: legs answered or given up on, out of all of them. */ +interface PdfRouting { + done: number; + total: number; } export interface TripExportOptions { @@ -67,6 +78,34 @@ export function flatDayNotes(dayNotes: Record) { return Object.entries(dayNotes).flatMap(([dayId, notes]) => notes.map((n) => ({ ...n, day_id: Number(dayId) }))); } +/** + * The PDF's routing round as the export rows show it. A cold month-long trip routes for + * minutes, one leg a second against the shared router, so the row counts the legs and + * offers to print without the ones still to come; those are marked in the document (#2876). + */ +function usePdfRouting() { + const [progress, setProgress] = useState(null); + const controller = useRef(null); + + const start = () => { + const routing = new AbortController(); + controller.current = routing; + // A leg already on its way still reports after a skip; the row stays quiet about it. + const onProgress = (done: number, total: number) => + setProgress(!routing.signal.aborted && done < total ? { done, total } : null); + return { signal: routing.signal, onProgress }; + }; + const finish = () => { + controller.current = null; + setProgress(null); + }; + const skip = () => { + controller.current?.abort(); + setProgress(null); + }; + return { progress, start, finish, skip }; +} + /** * Every way a trip leaves TREK, behind the desktop export dialog and the phone's * export sheet: the day plan as a PDF (the whole plan, or my plan once somebody @@ -88,6 +127,7 @@ export function useTripExport({ // Which export is working, per lane, so a row can say so instead of looking inert // while a 226 kB PDF builder is fetched and a document is rendered. const [running, setRunning] = useState>({}); + const pdfRouting = usePdfRouting(); // The PDF is built outside React, so it cannot read these itself (#2066). const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'; const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); @@ -108,11 +148,16 @@ export function useTripExport({ return next; }); + /** What a PDF row says while its route map is still being routed, instead of its own line. */ + const pdfRoutingLabel = (job: ExportJob) => + isRunning(job) && pdfRouting.progress ? t('dayplan.pdfRouting', { ...pdfRouting.progress }) : null; + const exportPdf = async (mine = false) => { const job: ExportJob = mine ? 'pdf:mine' : 'pdf'; if ((requireTrip && !data.trip) || running[laneOf(job)]) return; begin(job); const flatNotes = flatDayNotes(data.dayNotes); + const routing = pdfRouting.start(); try { // Loaded on click: the PDF builder is ~226 kB, so every trip used to pay for it // whether or not anyone exported. A missing chunk lands in the catch and shows @@ -132,12 +177,14 @@ export function useTripExport({ distanceUnit, showServiceStops, onlyUserId: mine ? myId : undefined, + route: { viasByDay: data.viasByDay, tourStageStops: data.tourStageStops, ...routing }, }); if (closeAfterPdf) onExported(); } catch (e) { if (logPdfErrors) console.error('PDF error:', e); toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`); } finally { + pdfRouting.finish(); end(job); } }; @@ -187,6 +234,9 @@ export function useTripExport({ isRunning, anyRunning: Object.keys(running).length > 0, exportPdf, + pdfRouting: pdfRouting.progress, + pdfRoutingLabel, + skipPdfRouting: pdfRouting.skip, downloadIcs, downloadGpx, }; diff --git a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx index b96ee8555..879ef6580 100644 --- a/client/src/mobile/screens/trip/sheets/MExportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MExportSheet.tsx @@ -23,7 +23,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { // Fed the way the desktop dialog feeds it: the store's assignments, and the export // applies the day plan's filter itself, so both shells print the same. The PDF stays // open here; a calendar or GPX file closes the sheet. - const { offerMine, isRunning, exportPdf, downloadIcs, downloadGpx } = useTripExport({ + const { offerMine, isRunning, exportPdf, pdfRouting, pdfRoutingLabel, skipPdfRouting, downloadIcs, downloadGpx } = useTripExport({ tripId: planner.tripId, data: { trip: planner.trip, @@ -33,6 +33,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { categories: planner.categories, reservations: planner.reservations, dayNotes, + viasByDay: planner.roadtripVias.byDay, + tourStageStops: planner.tourStageStops, }, t, locale, @@ -67,17 +69,26 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) { void exportPdf()} /> {offerMine && ( void exportPdf(true)} /> )} + {pdfRouting && ( + + )}