feat(tours): add non-motorized day tour planning

This commit is contained in:
elmocito
2026-10-04 12:59:55 +02:00
parent c93cf2bce9
commit 9e475f447a
202 changed files with 16129 additions and 383 deletions
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import type { RouteSegment } from '../../types'
import { useAddonStore } from '../../store/addonStore'
vi.mock('../Map/RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('../Map/RouteCalculator')>()
@@ -65,6 +66,8 @@ beforeEach(() => {
http.get('/api/pdf-sections/:tripId', () => HttpResponse.json({ sections: [] })),
)
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(routeTrip).mockClear()
vi.mocked(renderTripMapImage).mockClear()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.88, 2.39], [48.90, 2.42]],
distance: 12000, duration: 900,
@@ -74,9 +77,105 @@ beforeEach(() => {
afterEach(() => {
document.getElementById('pdf-preview-overlay')?.remove()
useAddonStore.setState({ addons: [] })
})
describe('trip route map in the PDF', () => {
it('prints a single Tour track without requesting an ordinary route through it', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const geometry = JSON.stringify([[48.1, 11.1], [48.2, 11.2]])
const assignment = { ...assign(7, 1, 0, 48.1, 11.1), tour_place_id: 7, tour_route_geometry: geometry }
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [assignment] } })
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(srcdoc()).toContain('class="trip-map-svg"')
expect(srcdoc()).toContain('stroke="#14805e"')
})
it('filters a participant-hidden Tour before the shared projection and PDF map', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const geometry = JSON.stringify([[48.1, 11.1], [48.2, 11.2]])
const hiddenTour = { ...assign(7, 1, 0, 48.1, 11.1), tour_place_id: 7,
tour_route_geometry: geometry, participants: [{ user_id: 99, username: 'Other' }] }
const visible = { ...assign(8, 1, 1, 48.3, 11.3), participants: [{ user_id: 42, username: 'Reader' }] }
await downloadTripPDF({ ...args, days: [args.days[0]], onlyUserId: 42,
assignments: { '1': [hiddenTour, visible] } })
const input = vi.mocked(routeTrip).mock.calls[0][0]
expect(input.assignments['1'].map(assignment => assignment.id)).toEqual([visible.id])
expect(input.toursEnabled).toBe(true)
expect(srcdoc()).not.toContain('stroke="#14805e"')
expect(srcdoc()).not.toContain('class="trip-map"')
})
it('renders an excluded Tour once while its connector route bridges A to C', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(11, 1, 0, 48.1, 11.1)
const geometry = JSON.stringify([[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]])
const excludedTour = { ...assign(12, 1, 1, 48.2, 11.2), tour_place_id: 12,
tour_route_geometry: geometry, route_excluded: true }
const c = assign(13, 1, 2, 48.4, 11.4)
vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({
coordinates: [[48.1, 11.1], [48.4, 11.4]], distance: 12000, duration: 900,
legs: [leg(12000)],
})
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, excludedTour, c] } })
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.4, 11.4]]])
const routed = await vi.mocked(routeTrip).mock.results[0].value
expect(routed.days[0].tourLines).toEqual([[[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]]])
const html = srcdoc()
expect(html.match(/stroke="#14805e"/g)).toHaveLength(1)
expect(vi.mocked(renderTripMapImage).mock.calls[0][0]).toMatchObject([
{ lines: [[[48.1, 11.1], [48.4, 11.4]]], tourLines: [[[48.2, 11.2], [48.25, 11.25], [48.3, 11.3]]] },
])
})
it('filters hidden service stops before routing or Tour projection', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(41, 1, 0, 48.1, 11.1)
const service = { ...assign(42, 1, 1, 48.2, 11.2), place: { ...place(42, 48.2, 11.2), stop_type: 'fuel' } }
const c = assign(43, 1, 2, 48.4, 11.4)
await downloadTripPDF({ ...args, days: [args.days[0]], showServiceStops: false,
assignments: { '1': [a, service, c] } })
const input = vi.mocked(routeTrip).mock.calls[0][0]
expect(input.assignments['1'].map(assignment => assignment.id)).toEqual([a.id, c.id])
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.4, 11.4]]])
})
it('keeps the legacy Place anchor and does not emit Tour lines when Tours are off', async () => {
const a = assign(21, 1, 0, 48.1, 11.1)
const geometry = JSON.stringify([[48.2, 11.2], [48.3, 11.3]])
const tourPlace = { ...assign(22, 1, 1, 48.25, 11.25), tour_place_id: 22, tour_route_geometry: geometry }
const c = assign(23, 1, 2, 48.4, 11.4)
await downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, tourPlace, c] } })
expect(vi.mocked(routeTrip).mock.calls[0][0].toursEnabled).toBe(false)
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.25, 11.25], [48.4, 11.4]]])
expect(srcdoc()).not.toContain('stroke="#14805e"')
})
it('prints one localized warning for an invalid Tour and does not invent its outgoing endpoint', async () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never })
const a = assign(31, 1, 0, 48.1, 11.1)
const invalidTour = { ...assign(32, 1, 1, 48.2, 11.2), tour_place_id: 32, tour_route_geometry: 'not valid geometry' }
const c = assign(33, 1, 2, 48.4, 11.4)
await expect(downloadTripPDF({ ...args, days: [args.days[0]], assignments: { '1': [a, invalidTour, c] } })).resolves.toBeUndefined()
expect(vi.mocked(calculateRouteWithLegs).mock.calls.map(([points]) =>
points.map(point => [point.lat, point.lng])))
.toEqual([[[48.1, 11.1], [48.2, 11.2]]])
expect(srcdoc().match(/tours\.dayRoute\.endpointUnknown/g)).toHaveLength(1)
expect(srcdoc()).toContain('P32')
expect(srcdoc()).not.toContain('stroke="#14805e"')
})
it('FE-COMP-TRIPPDF-MAP-001: puts the route map and its legend in the document', async () => {
await downloadTripPDF(args)
const html = srcdoc()
+7
View File
@@ -12,6 +12,7 @@ import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime,
import { useSettingsStore } from '../../store/settingsStore'
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'
@@ -271,6 +272,9 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
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(
{
@@ -279,6 +283,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
reservations,
accommodations: accommodationList,
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
toursEnabled,
places,
},
{ profile: 'driving', tripId: trip.id, timeoutMs: 8000 },
)
@@ -932,6 +938,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
</div>
<!-- Trip map -->
${hasInvalidTour ? `<p role="status" style="color:#b45309">${escHtml(tr('tours.dayRoute.endpointUnknown'))}</p>` : ''}
${tripMapHtml}
<!-- Days -->
+2 -2
View File
@@ -39,8 +39,8 @@ export async function renderTripMapImage(
opts: TripMapImageOptions,
): Promise<string | null> {
const { width, height } = opts
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const points = drawn.flatMap(d => d.lines.flat())
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const points = drawn.flatMap(d => [...d.lines, ...(d.tourLines ?? [])].flat())
if (points.length < 2 || !hasWebGL()) return null
let container: HTMLDivElement | null = null
+10 -4
View File
@@ -53,8 +53,8 @@ function niceDistanceKm(roughKm: number): number {
*/
export function buildTripMapSvg(days: TripOverviewDay[], opts: TripMapSvgOptions): string | null {
const { width, height } = opts
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const points = drawn.flatMap(d => d.lines.flat()).map(([lat, lng]) => projectMercator(lng, lat))
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const points = drawn.flatMap(d => [...d.lines, ...(d.tourLines ?? [])].flat()).map(([lat, lng]) => projectMercator(lng, lat))
if (points.length < 2) return null
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
@@ -135,7 +135,7 @@ export interface RouteOverlayOptions {
export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOptions): string {
const { project, width, height, metresPerPx } = opts
const round = (n: number) => Math.round(n * 10) / 10
const drawn = days.filter(d => d.lines.some(line => line.length > 1))
const drawn = days.filter(d => [...d.lines, ...(d.tourLines ?? [])].some(line => line.length > 1))
const routes = drawn.flatMap(day => day.lines.filter(l => l.length > 1).map(line => {
const pts = line.map(([lat, lng]) => project(lat, lng).map(round).join(',')).join(' ')
@@ -146,6 +146,12 @@ export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOpt
}))
// Where each run starts and ends — the stops, rather than every vertex of the road.
const tours = drawn.flatMap(day => (day.tourLines ?? []).filter(line => line.length > 1).map(line => {
const pts = line.map(([lat, lng]) => project(lat, lng).map(round).join(',')).join(' ')
return `<polyline points="${pts}" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`
+ `<polyline points="${pts}" fill="none" stroke="#14805e" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>`
}))
const stops = drawn.flatMap(day => day.lines.filter(l => l.length > 1).flatMap(line => {
const ends = [line[0], line[line.length - 1]]
return ends.map(([lat, lng]) => {
@@ -165,7 +171,7 @@ export function buildRouteOverlay(days: TripOverviewDay[], opts: RouteOverlayOpt
+ ` font-family="Poppins, system-ui, sans-serif">${escapeText(opts.formatDistance(barKm))}</text>`
: ''
return routes.join('') + stops.join('') + scaleBar
return routes.join('') + tours.join('') + stops.join('') + scaleBar
}
/** The scale label is the only text here, and it is a number and a unit — but it comes