From f283254bb088f6c352c86dc3d221dd13eb251431 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:43:50 +0200 Subject: [PATCH 01/29] fix(reservations): keep a car booking's stops when it is saved on a phone The desktop form has offered stops between pick-up and return since the road trip release (#1797). The phone sheet never knew about them: its car branch rebuilt the endpoint list from from/to alone, and because the server replaces every endpoint row of a booking, saving there silently dropped whatever the desktop had set. Its car branch now reads the stop endpoints back into an editor and writes them out again, the same shape TransportModal uses: location, time, reorder arrows, remove. --- .../trip/sheets/MTransportFormSheet.tsx | 114 +++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index f96886d85..01daa1878 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react' +import { Bike, Bus, Car, CarTaxiFront, Check, ChevronDown, ChevronUp, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2, X } from 'lucide-react' import MSheet from '../../../components/MSheet' import { useAddonStore } from '../../../../store/addonStore' import { useTranslation } from '../../../../i18n' @@ -70,6 +70,16 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint return { name: e.name, lat: e.lat, lng: e.lng, address: null } } +// The places a driver planned between pick-up and return (#1797), the same shape the +// desktop TransportModal edits. Without an editor here the save below would rebuild the +// endpoint list from from/to alone and the server, which replaces every endpoint row of a +// booking, would drop the stops a phone never showed. +interface CarStopForm { + location: LocationPoint | null + time: string +} +const emptyCarStop = (): CarStopForm => ({ location: null, time: '' }) + // A flight is an ordered list of airports; N waypoints = N-1 legs. The origin // only departs, the destination only arrives, each stop does both. interface WaypointForm { @@ -154,6 +164,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const [toPick, setToPick] = useState({}) const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) + const [carStops, setCarStops] = useState([]) const [pendingFiles, setPendingFiles] = useState([]) // Travelers assigned to this booking (#1517) — seeded from the editing // reservation on open, persisted separately after the save resolves. @@ -296,6 +307,17 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo setToPick({ location: locationFromEndpoint(to) || undefined }) setWaypoints([emptyWaypoint(), emptyWaypoint()]) setTrainWaypoints([emptyStationWaypoint(), emptyStationWaypoint()]) + // Stops persist for every type; only a car offers an editor for them, so only a + // car reads them back into one. The others keep passing theirs through untouched. + setCarStops( + src.type === 'car' + ? (src.endpoints ?? []) + .filter(e => e.role === 'stop') + .slice() + .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) + .map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' })) + : [], + ) } } else { setForm({ ...EMPTY, start_day_id: transportModalDayId ?? '', end_day_id: transportModalDayId ?? '' }) @@ -303,6 +325,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo setToPick({}) setWaypoints([emptyWaypoint(transportModalDayId ?? ''), emptyWaypoint(transportModalDayId ?? '')]) setTrainWaypoints([emptyStationWaypoint(transportModalDayId ?? ''), emptyStationWaypoint(transportModalDayId ?? '')]) + setCarStops([]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showTransportModal]) @@ -311,6 +334,16 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const prefill = snap.prefill const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value })) + const moveCarStop = (index: number, delta: number): void => { + setCarStops(prev => { + const to = index + delta + if (to < 0 || to >= prev.length) return prev + const next = [...prev] + ;[next[index], next[to]] = [next[to], next[index]] + return next + }) + } + const toggleTraveler = (id: number) => setTravelerIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) @@ -491,7 +524,14 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo }) } else { if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null)) - const stops = keepTransit + // A car writes the stops the driver planned; every other type keeps passing the + // itinerary's transfer stops through while the route is unchanged (#1065). + const carEndpoints = form.type === 'car' + ? carStops + .filter(s => s.location) + .map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null)) + : [] + const stops = keepTransit && form.type !== 'car' ? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) : [] stops.forEach((s, i) => endpoints.push({ @@ -499,7 +539,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo lat: s.lat, lng: s.lng, timezone: s.timezone ?? null, local_date: s.local_date ?? null, local_time: s.local_time ?? null, })) - if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null)) + carEndpoints.forEach(e => endpoints.push(e)) + const stopCount = stops.length + carEndpoints.length + if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null)) } const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null @@ -890,6 +932,72 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo {t('reservations.meta.to')} setToPick({ location: l || undefined })} /> + {/* Stops along the drive — cars only (#1797). The rental frame above stays the + pick-up and return; these are the places in between, in order. */} + {form.type === 'car' && ( + <> + {t('roadtrip.stops.label')} +
+ {carStops.map((stop, i) => ( +
+ {/* The order of these stops IS the route: `sequence` is the array + index at save time. Arrows rather than dragging, because the + row already carries a location picker and a time picker. */} + {carStops.length > 1 && ( +
+ + +
+ )} +
+ setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))} + /> +
+
+ setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))} + /> +
+ +
+ ))} + +
+ + )} + {/* Departure row */}
From 15beb0ea40eea8164bb7924fb49ded4156ae14bc Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:01 +0200 Subject: [PATCH 02/29] feat(roadtrip): work back from the first pinned time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule only ever walked forward, so a stop before the first pinned time stayed blank. That is the wrong way round for how people plan: the museum opens at ten, so when do we have to leave? Pinning the time on the second stop and reading the first one's departure off the chain is the whole point of pinning it. The chain now runs both ways — forward from the first known arrival as before, backward for everything ahead of it, departure being the next arrival minus the drive and arrival that minus the stay. It stops at a leg that never routed rather than inventing a duration, and when working back crosses midnight the whole chain shifts up a day so the earliest stop stays on day zero and the boundary is drawn between the two stops instead of under the first one. --- .../components/Roadtrip/roadtripModel.test.ts | 38 ++++++++++- .../src/components/Roadtrip/roadtripModel.ts | 63 ++++++++++++++----- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 0b7ea966a..9dfc297d2 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -89,7 +89,10 @@ describe('computeSchedule', () => { expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }) }) - it('leaves stops before the first pinned time blank rather than inventing one', () => { + it('works back from the first pinned time to say when to set off', () => { + // The museum opens at ten and we want an hour at the stop before it, an hour of + // driving in between: be there at eight. Answering that is the reason to pin a time + // on the second stop at all. const { entries } = computeSchedule( [ { anchor: null, dwellMinutes: 60 }, @@ -97,10 +100,41 @@ describe('computeSchedule', () => { ], [hours(1)], ) - expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false }) expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) }) + it('stops working back at a leg that never routed rather than inventing one', () => { + const { entries } = computeSchedule( + [ + { anchor: null, dwellMinutes: 60 }, + { anchor: null, dwellMinutes: 30 }, + { anchor: '10:00', dwellMinutes: 30 }, + ], + [undefined, hours(1)], + ) + expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + // Leaves at 09:00 to arrive at 10:00, and its own half hour puts it there at 08:30. + expect(entries[1]).toMatchObject({ arrival: '08:30', departure: '09:00' }) + expect(entries[2]).toMatchObject({ arrival: '10:00', anchored: true }) + }) + + it('keeps the earliest stop on day zero when working back crosses midnight', () => { + // Pinned at one in the morning with three hours of driving before it: the stop before + // is the evening before. The chain shifts up a day rather than printing a day below + // zero, so the boundary shows between the two stops instead of under the first one. + const { entries, warnings } = computeSchedule( + [ + { anchor: null, dwellMinutes: 0 }, + { anchor: '01:00', dwellMinutes: 0 }, + ], + [hours(3)], + ) + expect(entries[0]).toMatchObject({ arrival: '22:00', dayOffset: 0 }) + expect(entries[1]).toMatchObject({ arrival: '01:00', dayOffset: 1, anchored: true }) + expect(warnings).toContainEqual({ index: 1, code: 'overnight' }) + }) + it('restarts the cascade at a pinned stop instead of pushing it', () => { // The drive would arrive at 09:00, but the museum ticket says 11:00: everything // after it counts from 11:00, not from the drive. diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index c61a8cefd..3b093ef78 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -212,8 +212,10 @@ function resolveArrival( } export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { - const entries: ScheduleEntry[] = [] const warnings: ScheduleWarning[] = [] + // Arrivals in minutes, so the chain can be walked twice before anything is formatted. + const arrivals: (number | null)[] = new Array(stops.length).fill(null) + const anchored: boolean[] = new Array(stops.length).fill(false) // Minutes since the first stop's midnight, so a chain crossing midnight keeps counting. let cursor: number | null = null let dayOffset = 0 @@ -224,28 +226,57 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset) if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }) - if (arrival === null) { + if (arrival === null) continue + + const offset = Math.floor(arrival / DAY_MINUTES) + if (offset > dayOffset) dayOffset = offset + + arrivals[i] = arrival + anchored[i] = anchor !== null + + const leg = legSeconds[i] + cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60) + } + + // Then backwards, for the stops the forward walk left blank because nobody had pinned + // a time yet. Pinning a time on the second stop is the ordinary way to plan: the museum + // opens at ten, so when do we have to leave? Working back from the first known arrival + // answers it — departure = the next arrival minus the drive, arrival = that minus the + // stay — and it stops at the first leg that never routed rather than inventing one. + const firstKnown = arrivals.findIndex(a => a !== null) + for (let i = firstKnown - 1; i >= 0; i--) { + const leg = legSeconds[i] + const next = arrivals[i + 1] + if (leg === undefined || next === null) break + arrivals[i] = next - Math.round(leg / 60) - (stops[i].dwellMinutes ?? 0) + } + + // Working back can land before the anchor day's midnight, which would print as a + // negative day. `dayOffset` counts days past the FIRST stop, so the whole chain shifts + // up instead until the earliest stop sits on day zero again. + const earliest = arrivals.reduce((m, a) => (a === null ? m : m === null || a < m ? a : m), null) + const shift = earliest === null || earliest >= 0 ? 0 : -Math.floor(earliest / DAY_MINUTES) * DAY_MINUTES + + const entries: ScheduleEntry[] = [] + let lastOffset = 0 + for (let i = 0; i < stops.length; i++) { + const raw = arrivals[i] + if (raw === null) { entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 }) continue } - - const offset = Math.floor(arrival / (24 * 60)) - if (offset > dayOffset) { - dayOffset = offset - warnings.push({ index: i, code: 'overnight' }) - } - - const dwell = stop.dwellMinutes ?? 0 - const departure = arrival + dwell + const arrival = raw + shift + const offset = Math.floor(arrival / DAY_MINUTES) + // Read off the finished chain rather than during the forward walk, so a midnight the + // backward pass introduced is marked too. + if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' }) + lastOffset = offset entries.push({ arrival: formatClock(arrival), - departure: formatClock(departure), - anchored: anchor !== null, + departure: formatClock(arrival + (stops[i].dwellMinutes ?? 0)), + anchored: anchored[i], dayOffset: offset, }) - - const leg = legSeconds[i] - cursor = leg === undefined ? null : departure + Math.round(leg / 60) } return { entries, warnings } From 149b87fd479756da9f5ac452f1f0e2e7425c812d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:10 +0200 Subject: [PATCH 03/29] feat(roadtrip): give the day boundary its own band, and flag a late pause Three things the rail was quietly getting wrong at a day boundary. A stop reached later than the time set on it showed a warning only when it carried a number. The schedule restarts its chain at any anchor, whatever kind of stop holds it, so the finding was computed for a fuel or charging halt too and then thrown away. Both branches now draw it, from one component, and it sits in the badge row beside the stay rather than on a line of its own. The midnight crossing was a small pill floating in the middle of the rail, which read as a badge belonging to the stop underneath. It is now a band across the full width that breaks the dashed line instead of letting it run through, with the mascot asleep in the gap. Both its animations stop under prefers-reduced-motion. And the stay dialog said 'leave 01:00' for a departure the chain places on the next day, because the clock wraps modulo 24 h. It now carries the same day marker the rail has always shown beside such a time. --- .../Roadtrip/RoadtripSidebar.test.tsx | 17 ++++ .../components/Roadtrip/RoadtripSidebar.tsx | 96 ++++++++++++++----- .../Roadtrip/RoadtripStayModal.test.tsx | 17 ++++ .../components/Roadtrip/RoadtripStayModal.tsx | 15 ++- client/src/index.css | 20 ++++ 5 files changed, 142 insertions(+), 23 deletions(-) diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 715746b74..aef384da9 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -302,6 +302,23 @@ describe('RoadtripSidebar', () => { expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null }) }) + it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => { + // The schedule restarts its chain at any pinned time, whatever kind of stop carries + // it, so it computes the finding for a fuel halt too. The rail used to hand `late` + // only to the numbered branch and throw the pause's own away. + const stops = [ + stop({ assignmentId: 1, name: 'Hamburg' }), + stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }), + ] + const schedule = { + entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })), + warnings: [{ index: 1, code: 'late' as const, minutes: 45 }], + } + wrap() + + expect(screen.getByLabelText(/45/)).toBeInTheDocument() + }) + it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => { const stops = [ stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }), diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index c68e90026..5b8906147 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react' import { - CarFront, Footprints, Bike, Zap, AlertTriangle, Moon, + CarFront, Footprints, Bike, Zap, AlertTriangle, ParkingSquare, Shuffle, Fuel, Clock, Spline, type LucideIcon, } from 'lucide-react' +import MDancingTrek from '../../mobile/components/MDancingTrek' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' @@ -377,9 +378,12 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { * the trip is actually for. Its own icon on one flat disc: three kinds of pause that all * mean "we are still driving", and the icon is what tells them apart. */ -function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditStay, onPickKind }: { +function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind }: { stop: RoadtripStop entry: ScheduleEntry | undefined + /** How late the drive reaches a time pinned on this pause, the same finding a numbered + * stop shows. A fuel or charging halt can carry a pinned time like anything else. */ + late: ScheduleWarning | undefined /** Findings about the drive that ARRIVES here. A charging halt is a stop like any other * as far as the tank is concerned, so it carries them the same way a numbered one does. */ driveFindings?: ScheduleWarning[] @@ -459,6 +463,7 @@ function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditSta {spurWorthLabelling(stop.offRoadMeters) ? : null} {(driveFindings ?? []).map(w => )} + {entry?.arrival ? : null} @@ -566,20 +571,42 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea * The schedule has carried this since it was written and nothing ever drew it: a day * running past midnight showed "02:30" as if it were tonight. */ +/** + * Where the chain crosses midnight. + * + * The one band in the rail that breaks the line instead of hanging off it, and the only + * thing here that moves: a night is not a place and not a drive, it is the day running + * out, and the rail should look like it stops. So it spans the marker column too, the + * dashes end above it and start again below, and the mascot dozes in the gap with three + * z's drifting off it. + * + * The ground is mixed rather than the soft info token: that token is translucent in the + * dark theme, and the mascot cuts its eyes out in the colour behind it, which would show + * the body through them. Mixing against the card keeps it opaque in both themes. + */ function OvernightBreak(): React.ReactElement { const { t } = useTranslation() + const night = 'color-mix(in srgb, var(--info) 13%, var(--bg-card))' return ( -
- - - +
+ - {t('roadtrip.warn.overnight')} + {/* Three z's on one baseline, each starting a third of the loop after the last, so + one is always on its way up. Purely decorative: the word beside it already says + what this band is. */} + + z + z + z +
) } @@ -651,8 +678,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s onEditStay?: () => void }): React.ReactElement { const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const lateText = late ? t('roadtrip.warn.late', { minutes: late.minutes ?? 0 }) : null return (
) : null} diff --git a/client/src/index.css b/client/src/index.css index a0ec5300e..9d09c5205 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -523,6 +523,20 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti will-change: transform, opacity; } +/* Roadtrip: die Zzz am Tageswechsel im Streckenstrang. Drei Buchstaben auf einer + Grundlinie, jeder ein Drittel des Laufs nach dem vorigen, damit immer einer + unterwegs ist. Rein dekorativ — das Wort daneben sagt schon, was das Band ist. */ +@keyframes trek-doze { + 0% { opacity: 0; transform: translate(0, 3px) scale(0.7); } + 22% { opacity: 0.9; } + 100% { opacity: 0; transform: translate(6px, -10px) scale(1.15); } +} +.trek-doze > * { + animation: trek-doze 3s ease-out infinite; +} +.trek-doze > *:nth-child(2) { animation-delay: 1s; } +.trek-doze > *:nth-child(3) { animation-delay: 2s; } + /* Stagger-Helpers für Listen — Enter-Animation mit Offset */ @keyframes trek-fade-up { from { opacity: 0; transform: translateY(6px); } @@ -551,6 +565,12 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti animation: none; background: var(--bg-tertiary); } + /* Die Zzz stehen still statt zu treiben — der Tageswechsel bleibt lesbar. */ + .trek-doze > * { + animation: none; + opacity: 0.6; + transform: none; + } button:not(:disabled):not([data-no-press]):active, [role="button"]:not([aria-disabled="true"]):not([data-no-press]):active, [data-press]:active { From a46d3fe88ac9ad44be8b9ae4f32bf564d3144201 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:21 +0200 Subject: [PATCH 04/29] fix(i18n): reword three literal German road trip strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Wähle aus, was du brauchst, und suche' is a word-for-word rendering of 'Pick what you need and search' that no German speaker would write; 'Wieder ein Ziel' is not a sentence a button can carry; and 'Alle Leistungen' reads as services rather than charging power in a dropdown of kilowatts. --- shared/src/i18n/de/roadtrip.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 8245a438e..7b248926f 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -38,9 +38,9 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.nightAction': 'Als Übernachtung hinzufügen', 'roadtrip.stop.makeService': 'Zum Halt unterwegs machen', 'roadtrip.stop.changeKind': 'Art des Halts ändern', - 'roadtrip.stop.backToDestination': 'Wieder ein Ziel', + 'roadtrip.stop.backToDestination': 'Wieder zum Ziel machen', 'roadtrip.poi.anySocket': 'Alle Stecker', - 'roadtrip.poi.anyPower': 'Alle Leistungen', + 'roadtrip.poi.anyPower': 'Beliebige Leistung', 'roadtrip.poi.free': 'Kostenlos', 'roadtrip.poi.wholeDay': 'Ganzer Tag', 'roadtrip.poi.midLeg': 'Auf halber Strecke, {from} bis {to}', @@ -78,7 +78,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.capped': 'Die Route ist lang — nur der erste Abschnitt wurde durchsucht.', 'roadtrip.poi.partial': '{count} Abschnitte konnten nicht durchsucht werden — die Ortssuche hat nicht geantwortet.', 'roadtrip.poi.failed': 'Die Ortssuche antwortet gerade nicht.', - 'roadtrip.poi.empty': 'Wähle aus, was du brauchst, und suche.', + 'roadtrip.poi.empty': 'Wähle eine Kategorie und starte die Suche.', 'roadtrip.poi.add': 'Übernehmen', 'roadtrip.poi.offRoute': '{distance} abseits der Route', 'roadtrip.poi.atStart': 'am Startpunkt', From 88129e454649c675c851270b64ace2787d2be0f4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:21 +0200 Subject: [PATCH 05/29] feat(maps): search the view in road trip mode too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The category pill was switched off whenever road trip mode was on, which left the corridor search as the only way to find anything. The two answer different questions though — 'what is along this drive' against 'what is in view right now' — and looking for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation at a stop for the same reason (disc #1797 thread). Both searches now draw together. A place found by both appears once, as the corridor knows it, because only that copy carries how far along the drive it sits, and the merged list keeps its reference while nothing changes so the map does not rebuild every pin on each render. --- .../components/Map/useMergedMapPois.test.ts | 59 +++++++++++++++++++ client/src/components/Map/useMergedMapPois.ts | 28 +++++++++ client/src/pages/TripPlannerPage.tsx | 18 +++--- 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 client/src/components/Map/useMergedMapPois.test.ts create mode 100644 client/src/components/Map/useMergedMapPois.ts diff --git a/client/src/components/Map/useMergedMapPois.test.ts b/client/src/components/Map/useMergedMapPois.test.ts new file mode 100644 index 000000000..1ecb7a2cd --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useMergedMapPois } from './useMergedMapPois' +import type { Poi } from './poiCategories' + +/** + * FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode. + * + * The corridor answers "what is along the drive", the category pill answers "what is in + * view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is + * only new when the content really is — the map rebuilds every pin off it. + */ + +const poi = (osm_id: string, name = osm_id): Poi => ({ + osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap', +} as Poi) + +describe('useMergedMapPois', () => { + it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => { + const explore = [poi('n1')] + const { result } = renderHook(() => useMergedMapPois(null, explore)) + expect(result.current).toBe(explore) + }) + + it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => { + const corridor = [poi('n1')] + const { result } = renderHook(() => useMergedMapPois(corridor, [])) + expect(result.current).toBe(corridor) + }) + + it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => { + const explore = [poi('n2')] + const { result } = renderHook(() => useMergedMapPois([], explore)) + expect(result.current).toBe(explore) + }) + + it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => { + const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')])) + expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2']) + }) + + it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => { + // The corridor copy carries how far along the drive it sits, which the pill's does not. + const corridor = [poi('n1', 'Hotel am Weg')] + const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')])) + expect(result.current).toBe(corridor) + expect(result.current).toHaveLength(1) + expect(result.current[0].name).toBe('Hotel am Weg') + }) + + it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => { + const corridor = [poi('n1')] + const explore = [poi('n2')] + const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore)) + const first = result.current + rerender() + expect(result.current).toBe(first) + }) +}) diff --git a/client/src/components/Map/useMergedMapPois.ts b/client/src/components/Map/useMergedMapPois.ts new file mode 100644 index 000000000..b6253671a --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react' +import type { Poi } from './poiCategories' + +/** + * The findings the map should draw, when two searches can be running at once. + * + * In road trip mode the corridor search answers "what is along this drive". The category + * pill answers a different question — "what is in view right now" — and a hotel at tonight's + * stop is exactly the case where someone wants the second one without giving up the first, + * so the two lists are drawn together rather than one replacing the other. + * + * Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles + * on: a hotel found both along the route and in the viewport is one pin, and the corridor's + * copy wins because it carries how far along the drive it sits. + * + * Returns the input array itself whenever there is nothing to merge. The map redraws off + * this reference, so handing it a fresh array on every render would rebuild every pin. + */ +export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] { + return useMemo(() => { + if (!corridor) return explore + if (!explore.length) return corridor + if (!corridor.length) return explore + const seen = new Set(corridor.map(p => p.osm_id)) + const extra = explore.filter(p => !seen.has(p.osm_id)) + return extra.length ? [...corridor, ...extra] : corridor + }, [corridor, explore]) +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7410395af..c531c8ad6 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -47,6 +47,7 @@ import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, T import { ListTodo, Download, Plus, Trash2, FolderPlus } from 'lucide-react' import { useTripPlanner } from './tripPlanner/useTripPlanner' import { usePoiExplore } from '../components/Map/usePoiExplore' +import { useMergedMapPois } from '../components/Map/useMergedMapPois' import PoiCategoryPill from '../components/Map/PoiCategoryPill' import { useTouchDragBridge } from '../hooks/useTouchDragBridge' @@ -320,7 +321,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { const [glMap, setGlMap] = useState(null) // The corridor search draws into the same map channel and answers the same question for // a drive, so the explore pill stands down while road trip mode is on. - const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false && !roadtripActive + // Also in road trip mode: searching the view is a different question from searching the + // drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the + // two answers are drawn side by side rather than one hiding the other. + const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false + const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois) // Costs expense editor opened from a booking modal (save-then-open). Lives at the // page level so it has tripMembers / base currency / current user available. @@ -425,12 +430,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { const r = reservations.find(x => x.id === rid) if (r) setMapTransportDetail(r) }} - pois={roadtripActive - /* `visible`, not `search.results`: the map is the picture of this very - list, and filtering the list while seventy pins stay on the map no - longer answers "which of these". */ - ? roadtripCorridor.visible - : poi.pois} + /* In road trip mode the corridor's `visible` (not `search.results`: the map is + the picture of that very list, and filtering the list while seventy pins stay + on the map no longer answers "which of these") plus whatever the category + pill found in view. Outside it, only the pill's hits. */ + pois={mapPois} onPoiClick={handlePoiClick} // Only while road trip mode is on: outside it there is no drive to drop onto. onPoiDropOnRoute={roadtripActive ? dropPoiOnRoute : undefined} From d30b916f9c3debbca9ea8863ea40fbff4852a545 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 14:44:32 +0200 Subject: [PATCH 06/29] feat(maps): count how much routing an instance actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every route is calculated in the browser against the public FOSSGIS hosts, so the server never sees a routing request and nobody has a number: not how many a real trip costs, not how big they are, not how much already goes to a self-hosted engine. Deciding whether TREK could run its own router without that number is guessing at the bill, and the counters have to be collecting before the question comes up. Counters, not a log. A row is a day, a profile, a request kind and an engine kind with totals on it: how many requests, how many waypoints, roughly how far, how many were refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the instance: its own database holds them and its own admin reads them at GET /api/route-usage/summary. The client tallies in memory and flushes in batches — on a timer, past a threshold and when the page is hidden — because a counter that cost a request of its own would measure the load by adding to it. Routing all funnels through one fetch in RouteCalculator, so that is the only place instrumented. On by default and switchable off with route_usage_enabled, unlike the shadow log next door: that one stores what people typed, this one stores four integers about a day. --- client/src/api/routeUsage.ts | 24 +++ client/src/components/Map/RouteCalculator.ts | 56 +++++- .../components/Map/routeUsageCounter.test.ts | 88 +++++++++ .../src/components/Map/routeUsageCounter.ts | 102 ++++++++++ server/src/db/migrations.ts | 31 +++ server/src/nest/app.module.ts | 3 +- .../route-usage/route-usage.controller.ts | 43 ++++ .../src/nest/route-usage/route-usage.dto.ts | 9 + .../src/nest/route-usage/route-usage.job.ts | 38 ++++ .../nest/route-usage/route-usage.module.ts | 17 ++ .../nest/route-usage/route-usage.service.ts | 185 ++++++++++++++++++ .../unit/nest/route-usage.service.test.ts | 136 +++++++++++++ shared/src/index.ts | 1 + shared/src/maps/route-usage.schema.ts | 103 ++++++++++ 14 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 client/src/api/routeUsage.ts create mode 100644 client/src/components/Map/routeUsageCounter.test.ts create mode 100644 client/src/components/Map/routeUsageCounter.ts create mode 100644 server/src/nest/route-usage/route-usage.controller.ts create mode 100644 server/src/nest/route-usage/route-usage.dto.ts create mode 100644 server/src/nest/route-usage/route-usage.job.ts create mode 100644 server/src/nest/route-usage/route-usage.module.ts create mode 100644 server/src/nest/route-usage/route-usage.service.ts create mode 100644 server/tests/unit/nest/route-usage.service.test.ts create mode 100644 shared/src/maps/route-usage.schema.ts diff --git a/client/src/api/routeUsage.ts b/client/src/api/routeUsage.ts new file mode 100644 index 000000000..12916f553 --- /dev/null +++ b/client/src/api/routeUsage.ts @@ -0,0 +1,24 @@ +import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared' +import { apiClient } from './client' + +/** + * Reports the routing counters this browser has tallied up. + * + * Fire and forget: it never throws and never blocks the route it is describing. A + * failed report is a lost count, which is a far smaller problem than a map that waits + * on a measurement. + * + * No client-side switch to check, unlike the shadow log. Counting is on unless an + * operator turned it off, and that is a server setting the server enforces — a report + * to a switched-off instance is answered with `recorded: false` and costs one request. + */ +export const routeUsageApi = { + report: (entries: RouteUsageEntry[]): Promise => + entries.length + ? apiClient.post('/route-usage/report', { entries }).then(() => undefined) + : Promise.resolve(), + + /** Admin only. The figures behind "could we host a router ourselves". */ + summary: (): Promise => + apiClient.get('/route-usage/summary').then(r => r.data), +} diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 788b20e5a..ce243bf40 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -3,6 +3,8 @@ import { pluginsApi } from '../../api/client' import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types' import { haversineKm } from '../../utils/geo' import { formatDistance } from '../../utils/units' +import { countRoute } from './routeUsageCounter' +import type { RouteUsageSurface } from '@trek/shared' // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // project-osrm.org demo is car-only (it ignores the profile in the URL). Use @@ -50,6 +52,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string { return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}` } +/** + * `fetch`, with the request counted. + * + * Every route TREK draws goes out from here, and nowhere else, which makes this the one + * place that can answer how much routing an instance really does — the number behind + * "could we host an engine ourselves". Counted: how many requests, of what kind, how + * many waypoints, roughly how far, and whether the host refused. Not counted, because it + * is never sent: where any of it was. + * + * Distance is the straight line along the waypoint chain rather than the routed length, + * which is only in the answer and differs per response shape. It is a floor on the real + * figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one. + * + * An aborted request is not a failure: the map cancels constantly while someone drags. + */ +async function routedFetch( + url: string, + signal: AbortSignal | undefined, + kind: RouteUsageSurface, + profile: 'driving' | 'walking' | 'cycling', + waypoints: readonly Waypoint[], +): Promise { + const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim() + let km = 0 + for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i]) + const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km } + try { + const response = await fetch(url, { signal }) + countRoute({ ...sample, failed: !response.ok }) + return response + } catch (err) { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + countRoute({ ...sample, failed: true }) + } + throw err + } +} + /** * A routing request the host refused, as opposed to one it answered with "no route". * @@ -114,7 +154,7 @@ export async function calculateRoute( const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'route', profile, waypoints) if (!response.ok) { throw new RoutingRefusedError(response.status, retryAfterMs(response)) } @@ -321,7 +361,7 @@ export async function calculateSegments( const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'segments', 'driving', waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() @@ -416,7 +456,7 @@ export async function calculateRouteWithLegs( const osrmProfile: 'driving' | 'walking' | 'cycling' = profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving' const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() @@ -572,7 +612,13 @@ async function routeExcluding( if (excludeUnsupported.has(base)) return null try { const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson` - const response = await fetch(url, { signal }) + // This one is handed its points already encoded, so they are read back out for the + // counter rather than threaded through a second parameter nobody else needs. + const points = coords.split(';').map(pair => { + const [lng, lat] = pair.split(',').map(Number) + return { lat, lng } + }) + const response = await routedFetch(url, signal, 'alternatives', profile, points) if (!response.ok) { // 400 is the router saying the parameter itself is not available here, which is // true of every leg from now on. Anything else is about this request alone. @@ -614,7 +660,7 @@ export async function calculateAlternatives( ): Promise { const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson` - const response = await fetch(url, { signal }) + const response = await routedFetch(url, signal, 'alternatives', profile, [from, to]) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() diff --git a/client/src/components/Map/routeUsageCounter.test.ts b/client/src/components/Map/routeUsageCounter.test.ts new file mode 100644 index 000000000..ac5802cef --- /dev/null +++ b/client/src/components/Map/routeUsageCounter.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { countRoute, flushRouteUsage } from './routeUsageCounter' +import { routeUsageApi } from '../../api/routeUsage' + +/** + * FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do". + * + * Two things are worth pinning: that it batches rather than posting per route, because a + * counter that costs a request of its own measures the load by adding to it, and that it + * never throws — it sits on the hot path of every route the map draws. + */ + +const sample = { + profile: 'driving' as const, + surface: 'legs' as const, + selfHosted: false, + waypoints: 3, + km: 100, + failed: false, +} + +describe('routeUsageCounter', () => { + beforeEach(() => { + vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined) + vi.useFakeTimers() + }) + + afterEach(() => { + flushRouteUsage() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => { + countRoute(sample) + expect(routeUsageApi.report).not.toHaveBeenCalled() + }) + + it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => { + countRoute(sample) + vi.advanceTimersByTime(60_000) + + expect(routeUsageApi.report).toHaveBeenCalledTimes(1) + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }]) + }) + + it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => { + countRoute(sample) + countRoute({ ...sample, waypoints: 5, km: 200 }) + countRoute({ ...sample, failed: true }) + flushRouteUsage() + + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 }) + }) + + it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => { + countRoute(sample) + countRoute({ ...sample, surface: 'alternatives' }) + countRoute({ ...sample, selfHosted: true }) + countRoute({ ...sample, profile: 'walking' }) + flushRouteUsage() + + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries).toHaveLength(4) + }) + + it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => { + for (let i = 0; i < 25; i++) countRoute(sample) + expect(routeUsageApi.report).toHaveBeenCalledTimes(1) + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0] + expect(entries[0].requests).toBe(25) + }) + + it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => { + vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline')) + countRoute(sample) + expect(() => flushRouteUsage()).not.toThrow() + await Promise.resolve() + + countRoute(sample) + flushRouteUsage() + const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1] + expect(entries[0].requests).toBe(1) + }) +}) diff --git a/client/src/components/Map/routeUsageCounter.ts b/client/src/components/Map/routeUsageCounter.ts new file mode 100644 index 000000000..0dfe05361 --- /dev/null +++ b/client/src/components/Map/routeUsageCounter.ts @@ -0,0 +1,102 @@ +import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared' +import { routeUsageApi } from '../../api/routeUsage' + +/** + * Counts the routing this browser asks for, and posts the totals in batches. + * + * All routing happens client-side against an external engine, so the server never + * sees a route request and cannot count one. Without a count nobody knows what a + * self-hosted router would have to carry — how many requests a real trip costs, how + * big they are, how much already goes to an operator's own engine. + * + * Counters only. What goes over the wire is four integers per bucket: how many + * requests, how many waypoints, how many kilometres, how many failed. No coordinate, + * no route, no trip, nothing that says where anybody went. + * + * Batched on purpose. A counter that cost a request of its own would measure the load + * by adding to it, so the tally is held in memory and flushed on a timer, when it + * grows past a threshold, and when the page goes away. + */ + +/** Flush when the tally has this many requests in it, whichever comes first. */ +const FLUSH_AFTER_REQUESTS = 25 +/** …or after this long, so a quiet session still reports what it did. */ +const FLUSH_AFTER_MS = 60_000 +/** Guard against a pathological session: drop the tally rather than grow forever. */ +const MAX_BUCKETS = 40 + +type Bucket = Omit & { + profile: RouteUsageProfile + surface: RouteUsageSurface + selfHosted: boolean +} + +const buckets = new Map() +let pending = 0 +let timer: ReturnType | null = null +let unloadWired = false + +function flush(): void { + if (timer !== null) { clearTimeout(timer); timer = null } + if (!buckets.size) return + const entries = [...buckets.values()] + buckets.clear() + pending = 0 + // Fire and forget: a failed report is a lost count, not a problem worth telling + // anybody about. The next flush carries on from an empty tally either way. + void routeUsageApi.report(entries).catch(() => {}) +} + +function wireUnload(): void { + if (unloadWired || typeof document === 'undefined') return + unloadWired = true + // `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile, + // where a session ends by switching apps rather than by closing a tab. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flush() + }) +} + +/** + * Records one routing request. Never throws and never awaits: it sits on the hot path + * of every route the map draws. + */ +export function countRoute(sample: { + profile: RouteUsageProfile + surface: RouteUsageSurface + selfHosted: boolean + waypoints: number + km: number + failed: boolean +}): void { + try { + wireUnload() + const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}` + const bucket = buckets.get(key) + if (bucket) { + bucket.requests += 1 + bucket.waypoints += sample.waypoints + bucket.km += sample.km + if (sample.failed) bucket.failed += 1 + } else { + if (buckets.size >= MAX_BUCKETS) return + buckets.set(key, { + profile: sample.profile, + surface: sample.surface, + selfHosted: sample.selfHosted, + requests: 1, + waypoints: sample.waypoints, + km: sample.km, + failed: sample.failed ? 1 : 0, + }) + } + pending += 1 + if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return } + if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS) + } catch { /* counting must never break routing */ } +} + +/** Flushes now. Exported for tests and for a deliberate "report before you go". */ +export function flushRouteUsage(): void { + flush() +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 52e79a542..f2e9c6408 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4483,6 +4483,37 @@ function runMigrations(db: Database.Database): void { )`); db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)'); }, + /** + * Route usage counters — how much routing this instance really does. + * + * Daily aggregates, not a log: one row per day, profile, surface and engine + * kind, carrying totals. No query, no coordinate, no route, no user, no trip. + * The question they answer is whether TREK could host a router itself, and + * that needs volume, not itineraries. + * + * The table is created regardless of the switch, like the shadow log above: + * the switch decides whether rows are written, and a schema that appears only + * when a feature is on is a schema that differs between installs. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS route_usage_daily ( + day TEXT NOT NULL, + profile TEXT NOT NULL, + surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, + km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ) + `); + // Retention deletes by day, and the summary reads the newest days first. + db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 8faacb667..e567b7b96 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -22,6 +22,7 @@ import { MapsModule } from './maps/maps.module'; import { GeoModule } from './geo/geo.module'; import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module'; import { PlaceShadowModule } from './place-shadow/place-shadow.module'; +import { RouteUsageModule } from './route-usage/route-usage.module'; import { CategoriesModule } from './categories/categories.module'; import { TagsModule } from './tags/tags.module'; import { NotificationsModule } from './notifications/notifications.module'; @@ -78,7 +79,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/route-usage/route-usage.controller.ts b/server/src/nest/route-usage/route-usage.controller.ts new file mode 100644 index 000000000..b026c901a --- /dev/null +++ b/server/src/nest/route-usage/route-usage.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import type { RouteUsageReportResult, RouteUsageSummaryResult } from '@trek/shared'; +import { AdminGuard } from '../auth/admin.guard'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RouteUsageReportDto } from './route-usage.dto'; +import { RouteUsageService } from './route-usage.service'; + +/** + * /api/route-usage — how much routing this instance does. + * + * Reporting is open to any signed-in user, because every signed-in user is the one + * generating the load; reading is admin only, because the totals are everybody's + * routing and the person entitled to see that is the person running the instance. + * Nothing here leaves the instance on its own. + */ +@Controller('api/route-usage') +@UseGuards(JwtAuthGuard) +export class RouteUsageController { + constructor(private readonly usage: RouteUsageService) {} + + /** + * 200 with `{ recorded: false }` when counting is off, rather than a 403. The + * client flushes and moves on; a rejection would be a permanent error in the + * console of every install that switched the counters off. + */ + @Post('report') + @HttpCode(200) + report(@Body() body: RouteUsageReportDto): RouteUsageReportResult { + return { recorded: this.usage.record(body) }; + } + + @Get('summary') + @UseGuards(JwtAuthGuard, AdminGuard) + summary(): RouteUsageSummaryResult { + return this.usage.summary(); + } + + @Delete() + @UseGuards(JwtAuthGuard, AdminGuard) + clear(): { removed: number } { + return { removed: this.usage.clear() }; + } +} diff --git a/server/src/nest/route-usage/route-usage.dto.ts b/server/src/nest/route-usage/route-usage.dto.ts new file mode 100644 index 000000000..aa04b525b --- /dev/null +++ b/server/src/nest/route-usage/route-usage.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { routeUsageReportRequestSchema } from '@trek/shared'; + +/** + * Zod-pipe wrapper for a batch of counters. Same pattern as every other write + * endpoint: the contract lives in @trek/shared, the class exists so a controller + * parameter can be typed with it. + */ +export class RouteUsageReportDto extends createZodDto(routeUsageReportRequestSchema) {} diff --git a/server/src/nest/route-usage/route-usage.job.ts b/server/src/nest/route-usage/route-usage.job.ts new file mode 100644 index 000000000..7019482a9 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.job.ts @@ -0,0 +1,38 @@ +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { RouteUsageService } from './route-usage.service'; + +/** + * Nightly retention for the routing counters. + * + * Runs even when counting is switched off, on purpose: switching it off should also + * let what it already counted age out, rather than freezing the rows in place until + * somebody remembers to press the wipe button. + * + * 3:45 AM, just after the shadow log's own sweep and clear of the 3:00 idempotency + * purge and the backup window. + */ +@Injectable() +export class RouteUsageRetentionJob implements OnApplicationBootstrap { + constructor( + private readonly usage: RouteUsageService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + this.registrar.register('route-usage-retention', '45 3 * * *', () => this.tick()); + } + + tick(): void { + try { + const removed = this.usage.purgeExpired(); + if (removed > 0) { + logInfo(`Route usage retention: removed ${removed} expired row(s)`); + } + } catch (err: unknown) { + logError(`Route usage retention: ${err instanceof Error ? err.message : err}`); + } + } +} diff --git a/server/src/nest/route-usage/route-usage.module.ts b/server/src/nest/route-usage/route-usage.module.ts new file mode 100644 index 000000000..09e818536 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { RouteUsageController } from './route-usage.controller'; +import { RouteUsageRetentionJob } from './route-usage.job'; +import { RouteUsageService } from './route-usage.service'; + +/** + * Routing usage counters. Registered in AppModule; exports the service so an admin + * surface can read the summary without going through HTTP. + */ +@Module({ + imports: [SchedulingModule], + controllers: [RouteUsageController], + providers: [RouteUsageService, RouteUsageRetentionJob], + exports: [RouteUsageService], +}) +export class RouteUsageModule {} diff --git a/server/src/nest/route-usage/route-usage.service.ts b/server/src/nest/route-usage/route-usage.service.ts new file mode 100644 index 000000000..79897c805 --- /dev/null +++ b/server/src/nest/route-usage/route-usage.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@nestjs/common'; +import type { + RouteUsageDayRow, + RouteUsageProfile, + RouteUsageReportRequest, + RouteUsageSummaryResult, + RouteUsageSurface, +} from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; + +/** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */ +export const RETENTION_DAYS = 400; + +/** Days returned in the summary's own series, newest first. */ +export const SERIES_DAYS = 90; + +interface DbRow { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; +} + +/** + * Route usage counters. + * + * All routing happens in the browser, so the server never sees a route request and + * cannot count one. The client keeps a small tally and posts it in batches; this + * adds those batches onto the day's row. + * + * On by default, unlike the shadow log next door. That log stores what people typed, + * so silence is the safe default; this stores four integers about a day with nothing + * in them that could belong to anybody, and it is worthless unless it is already + * running by the time the question comes up. An operator who wants none of it sets + * `route_usage_enabled` to `false`. + */ +@Injectable() +export class RouteUsageService { + constructor(private readonly db: DatabaseService) {} + + enabled(): boolean { + const row = this.db.get<{ value: string }>( + "SELECT value FROM app_settings WHERE key = 'route_usage_enabled'", + ); + // Absent means on: the counters have to be collecting before anyone thinks to + // look for them, and there is nothing here to protect. + return row?.value !== 'false'; + } + + /** + * Adds a batch onto today's rows. Returns whether anything was written, so a + * switched-off instance answers 200 rather than an error the client would log on + * every flush. + */ + record(report: RouteUsageReportRequest): boolean { + if (!this.enabled()) return false; + // One transaction for the batch: a flush is a handful of rows, and a partial + // one would leave a day counted twice on the client's next retry. + this.db.transaction(() => { + for (const entry of report.entries) { + this.db.run( + `INSERT INTO route_usage_daily (day, profile, surface, self_hosted, requests, waypoints, km, failed) + VALUES (date('now'), ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(day, profile, surface, self_hosted) DO UPDATE SET + requests = requests + excluded.requests, + waypoints = waypoints + excluded.waypoints, + km = km + excluded.km, + failed = failed + excluded.failed`, + entry.profile, + entry.surface, + entry.selfHosted ? 1 : 0, + entry.requests, + entry.waypoints, + Math.round(entry.km), + entry.failed, + ); + } + }); + return true; + } + + rows(): RouteUsageDayRow[] { + const rows = this.db.all( + 'SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface', + ); + return rows.map((r) => ({ + day: r.day, + profile: r.profile as RouteUsageProfile, + surface: r.surface as RouteUsageSurface, + selfHosted: r.self_hosted === 1, + requests: r.requests, + waypoints: r.waypoints, + km: r.km, + failed: r.failed, + })); + } + + summary(): RouteUsageSummaryResult { + const rows = this.rows(); + const enabled = this.enabled(); + const empty: RouteUsageSummaryResult = { + enabled, + retentionDays: RETENTION_DAYS, + daysCovered: 0, + firstDay: null, + lastDay: null, + totalRequests: 0, + totalFailed: 0, + requestsPerDay: 0, + busiestDay: null, + busiestDayRequests: 0, + waypointsPerRequest: 0, + kmPerRequest: 0, + byProfile: [], + bySurface: [], + selfHostedShare: 0, + days: [], + }; + if (!rows.length) return empty; + + const perDay = new Map(); + const perProfile = new Map(); + const perSurface = new Map(); + let requests = 0; + let failed = 0; + let waypoints = 0; + let km = 0; + let selfHosted = 0; + + for (const row of rows) { + requests += row.requests; + failed += row.failed; + waypoints += row.waypoints; + km += row.km; + if (row.selfHosted) selfHosted += row.requests; + perDay.set(row.day, (perDay.get(row.day) ?? 0) + row.requests); + perProfile.set(row.profile, (perProfile.get(row.profile) ?? 0) + row.requests); + perSurface.set(row.surface, (perSurface.get(row.surface) ?? 0) + row.requests); + } + + // Days come back newest first, so the last one is the earliest counted day. + const days = [...perDay.entries()].map(([day, count]) => ({ day, requests: count })); + const busiest = days.reduce((a, b) => (b.requests > a.requests ? b : a), days[0]); + const ratio = (part: number) => (requests > 0 ? Math.round((part / requests) * 1000) / 1000 : 0); + const per = (total: number) => (requests > 0 ? Math.round((total / requests) * 100) / 100 : 0); + + return { + enabled, + retentionDays: RETENTION_DAYS, + daysCovered: perDay.size, + firstDay: rows[rows.length - 1].day, + lastDay: rows[0].day, + totalRequests: requests, + totalFailed: failed, + requestsPerDay: perDay.size > 0 ? Math.round((requests / perDay.size) * 10) / 10 : 0, + busiestDay: busiest.day, + busiestDayRequests: busiest.requests, + waypointsPerRequest: per(waypoints), + kmPerRequest: per(km), + byProfile: [...perProfile.entries()].map(([profile, count]) => ({ profile, requests: count })), + bySurface: [...perSurface.entries()].map(([surface, count]) => ({ surface, requests: count })), + selfHostedShare: ratio(selfHosted), + days: days.slice(0, SERIES_DAYS), + }; + } + + /** Removes days past the retention window. Returns how many rows went. */ + purgeExpired(): number { + const result = this.db.run( + `DELETE FROM route_usage_daily WHERE day < date('now', ?)`, + `-${RETENTION_DAYS} days`, + ); + return result.changes ?? 0; + } + + /** Wipes every counter. The admin's own "start over". */ + clear(): number { + const result = this.db.run('DELETE FROM route_usage_daily'); + return result.changes ?? 0; + } +} diff --git a/server/tests/unit/nest/route-usage.service.test.ts b/server/tests/unit/nest/route-usage.service.test.ts new file mode 100644 index 000000000..01899bc4d --- /dev/null +++ b/server/tests/unit/nest/route-usage.service.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service'; +import type { RouteUsageEntry } from '@trek/shared'; + +/** + * SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router". + * + * Aggregates, not a log: the same day and kind adds onto one row, and the summary + * turns those rows into the two figures the decision needs, requests on an average + * day and requests on the busiest one. + */ + +function makeDb(): Database.Database { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE route_usage_daily ( + day TEXT NOT NULL, profile TEXT NOT NULL, surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ); + `); + return db; +} + +/** The slice of DatabaseService this domain uses. */ +function serviceOver(db: Database.Database): RouteUsageService { + const bridge = { + get: (sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined, + all: (sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[], + run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p), + transaction: (fn: () => T) => db.transaction(fn)(), + }; + return new RouteUsageService(bridge as never); +} + +const entry = (over: Partial = {}): RouteUsageEntry => ({ + profile: 'driving', surface: 'legs', selfHosted: false, + requests: 1, waypoints: 3, km: 100, failed: 0, ...over, +}); + +describe('RouteUsageService', () => { + let db: Database.Database; + let svc: RouteUsageService; + + beforeEach(() => { + db = makeDb(); + svc = serviceOver(db); + }); + + it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', () => { + expect(svc.enabled()).toBe(true); + db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + expect(svc.enabled()).toBe(false); + }); + + it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run(); + expect(svc.record({ entries: [entry()] })).toBe(false); + expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 }); + }); + + it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', () => { + svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] }); + svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] }); + + const rows = svc.rows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ requests: 8, waypoints: 18, km: 450, failed: 1 }); + }); + + it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', () => { + svc.record({ entries: [ + entry({ surface: 'legs' }), + entry({ surface: 'alternatives' }), + entry({ surface: 'legs', selfHosted: true }), + entry({ surface: 'legs', profile: 'walking' }), + ] }); + expect(svc.rows()).toHaveLength(4); + }); + + it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', () => { + const s = svc.summary(); + expect(s.totalRequests).toBe(0); + expect(s.daysCovered).toBe(0); + expect(s.busiestDay).toBeNull(); + expect(s.enabled).toBe(true); + expect(s.retentionDays).toBe(RETENTION_DAYS); + }); + + it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', () => { + // Two days by hand: the service always writes "today", so the spread is set here. + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run(); + + const s = svc.summary(); + expect(s.totalRequests).toBe(40); + expect(s.totalFailed).toBe(2); + expect(s.daysCovered).toBe(2); + expect(s.requestsPerDay).toBe(20); + expect(s.busiestDay).toBe('2026-09-02'); + expect(s.busiestDayRequests).toBe(30); + expect(s.firstDay).toBe('2026-09-01'); + expect(s.lastDay).toBe('2026-09-02'); + }); + + it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run(); + const s = svc.summary(); + expect(s.waypointsPerRequest).toBe(5); + expect(s.kmPerRequest).toBe(100); + }); + + it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run(); + expect(svc.summary().selfHostedShare).toBe(0.25); + }); + + it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', () => { + db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run(); + db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run(); + + expect(svc.purgeExpired()).toBe(1); + expect(svc.rows()).toHaveLength(1); + }); + + it('SRV-ROUTEUSAGE-010: clearing wipes every counter', () => { + svc.record({ entries: [entry(), entry({ surface: 'route' })] }); + expect(svc.clear()).toBe(2); + expect(svc.rows()).toHaveLength(0); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index f7c580386..32f7b5712 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -19,6 +19,7 @@ export * from './config/config.schema'; export * from './system-notice/system-notice.schema'; export * from './maps/maps.schema'; export * from './maps/place-shadow.schema'; +export * from './maps/route-usage.schema'; export * from './category/category.schema'; export * from './tag/tag.schema'; export * from './notification/notification.schema'; diff --git a/shared/src/maps/route-usage.schema.ts b/shared/src/maps/route-usage.schema.ts new file mode 100644 index 000000000..17842e0da --- /dev/null +++ b/shared/src/maps/route-usage.schema.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; + +/** + * Route usage counters — how much routing this instance actually does. + * + * The question behind them is whether TREK could host its own router. Today every + * route is calculated in the browser against the public FOSSGIS hosts, so nobody + * has a number: not how many requests a real trip costs, not how they split + * between the day plan and the road trip mode, not how many already go to a + * self-hosted engine. Deciding on a hosted router without that number would be + * guessing at the bill. + * + * Counters, not a log. A row is a day, a profile and a surface with totals on it — + * there is no query, no coordinate, no route, no user, no trip and no session. The + * only thing recorded about a request is that it happened, what kind it was, and + * how big it was. Nothing here leaves the instance on its own: the totals sit in + * that instance's own database and its own admin reads them. + * + * Reported in batches rather than per request. A counter that costs a network call + * of its own would measure load by adding it. + */ + +/** The three OSRM profiles TREK asks for, plus a bucket for a routing plugin. */ +export const routeUsageProfileSchema = z.enum(['driving', 'walking', 'cycling', 'other']); +export type RouteUsageProfile = z.infer; + +/** + * What kind of request it was, named after the call that made it rather than after + * the screen it came from: the day plan and road trip mode share `legs`, so a screen + * label would have to be threaded through every caller to be true. The split still + * carries the hosting question, because the kinds cost very different amounts — + * `legs` is one request for a whole chain, `alternatives` asks two or three times + * over for a single leg. + */ +export const routeUsageSurfaceSchema = z.enum(['route', 'segments', 'legs', 'alternatives', 'other']); +export type RouteUsageSurface = z.infer; + +export const routeUsageEntrySchema = z.object({ + profile: routeUsageProfileSchema, + surface: routeUsageSurfaceSchema, + /** True when the instance points at its own engine, so its load is already local. */ + selfHosted: z.boolean(), + /** Requests in this batch. Capped well above any plausible batch. */ + requests: z.number().int().min(1).max(10_000), + /** Waypoints handed over in total, which is what a per-request limit binds on. */ + waypoints: z.number().int().min(0).max(1_000_000), + /** Routed distance in kilometres, summed. Rounded to whole kilometres. */ + km: z.number().min(0).max(10_000_000), + /** How many of them came back with no route at all. */ + failed: z.number().int().min(0).max(10_000), +}); +export type RouteUsageEntry = z.infer; + +export const routeUsageReportRequestSchema = z.object({ + entries: z.array(routeUsageEntrySchema).min(1).max(40), +}); +export type RouteUsageReportRequest = z.infer; + +/** Answered even when counting is switched off, so the client never has to care. */ +export const routeUsageReportResultSchema = z.object({ + recorded: z.boolean(), +}); +export type RouteUsageReportResult = z.infer; + +export const routeUsageDayRowSchema = z.object({ + day: z.string(), + profile: routeUsageProfileSchema, + surface: routeUsageSurfaceSchema, + selfHosted: z.boolean(), + requests: z.number().int(), + waypoints: z.number().int(), + km: z.number(), + failed: z.number().int(), +}); +export type RouteUsageDayRow = z.infer; + +export const routeUsageSummaryResultSchema = z.object({ + enabled: z.boolean(), + /** Days a row survives. Aggregates are tiny, so this is far longer than a log's. */ + retentionDays: z.number().int(), + /** Days that have any counter at all, which is what the averages divide by. */ + daysCovered: z.number().int(), + firstDay: z.string().nullable(), + lastDay: z.string().nullable(), + totalRequests: z.number().int(), + totalFailed: z.number().int(), + /** The headline for the hosting question: requests on an average counted day. */ + requestsPerDay: z.number(), + /** Busiest single day, because a router has to survive the peak, not the mean. */ + busiestDay: z.string().nullable(), + busiestDayRequests: z.number().int(), + /** Mean waypoints per request: the figure a 10-waypoint API limit binds on. */ + waypointsPerRequest: z.number(), + /** Mean routed kilometres per request, against a 1500 km limit. */ + kmPerRequest: z.number(), + byProfile: z.array(z.object({ profile: routeUsageProfileSchema, requests: z.number().int() })), + bySurface: z.array(z.object({ surface: routeUsageSurfaceSchema, requests: z.number().int() })), + /** How much of the load already goes to an engine the operator runs. */ + selfHostedShare: z.number(), + /** The counted days themselves, newest first, for a chart or an export. */ + days: z.array(z.object({ day: z.string(), requests: z.number().int() })), +}); +export type RouteUsageSummaryResult = z.infer; From 756e0fb908f6b261c40c2c3de01cdd753fdf5951 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 15:21:20 +0200 Subject: [PATCH 07/29] fix(places): let the pool follow the open day, and say that it does The map has narrowed to the selected day on the Planned filter since #2024. The pool beside it did not, so a trip with 55 planned places read 55 in the tab and drew five pins on the map, with nothing anywhere to say the two were answering different questions. Reported twice in the same thread as the map being broken: you pick a day, open Places, tap Planned, see everything you ever planned, and the map shows a handful of it. The list and the tab count now use the same set the map does. Unplanned deliberately stays trip-wide: a place assigned to some other day is planned, whichever day happens to be open. A note under the tabs says so and closes the day when dismissed. It sits there rather than over the map because a chip on the canvas is out of reach on a phone, which is where this was reported from. --- .../components/Planner/PlacesSidebar.test.tsx | 107 ++++++++++++++++++ .../Planner/PlacesSidebarHeader.tsx | 46 +++++++- .../components/Planner/usePlacesSidebar.ts | 36 +++++- client/src/pages/TripPlannerPage.tsx | 3 +- shared/src/i18n/ar/places.ts | 2 + shared/src/i18n/br/places.ts | 2 + shared/src/i18n/ca/places.ts | 2 + shared/src/i18n/cs/places.ts | 2 + shared/src/i18n/de/places.ts | 2 + shared/src/i18n/en/places.ts | 3 + shared/src/i18n/es/places.ts | 2 + shared/src/i18n/fr/places.ts | 2 + shared/src/i18n/gr/places.ts | 2 + shared/src/i18n/hu/places.ts | 2 + shared/src/i18n/id/places.ts | 2 + shared/src/i18n/it/places.ts | 2 + shared/src/i18n/ja/places.ts | 2 + shared/src/i18n/ko/places.ts | 2 + shared/src/i18n/nl/places.ts | 2 + shared/src/i18n/pl/places.ts | 2 + shared/src/i18n/ru/places.ts | 2 + shared/src/i18n/sv/places.ts | 2 + shared/src/i18n/tr/places.ts | 2 + shared/src/i18n/uk/places.ts | 2 + shared/src/i18n/vi/places.ts | 2 + shared/src/i18n/zh-TW/places.ts | 2 + shared/src/i18n/zh/places.ts | 2 + 27 files changed, 230 insertions(+), 9 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index 96e1a7304..e5d99f6b9 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -288,6 +288,113 @@ describe('Filter tabs', () => { expect(screen.getByText('Planned Place')).toBeInTheDocument(); expect(screen.queryByText('Unplanned Place')).not.toBeInTheDocument(); }); + + // ── The open day narrows the pool, and says so ────────────────────────────── + // + // The map has followed the selected day on this filter since #2024 while the list + // did not, so a trip with everything planned read "55" in the pool beside five pins + // on the map. Reported twice in the same Discord thread as the map being broken. + + it('FE-PLANNER-SIDEBAR-052: with a day open, "Planned" shows that day, not the whole trip', () => { + const today = buildPlace({ id: 91, name: 'On This Day' }); + const otherDay = buildPlace({ id: 92, name: 'On Another Day' }); + const assignments = { + '1': [buildAssignment({ place: today, day_id: 1 })], + '2': [buildAssignment({ place: otherDay, day_id: 2 })], + }; + seedStore(useTripStore, { placesFilter: 'planned' }); + render( + , + ); + + expect(screen.getByText('On This Day')).toBeInTheDocument(); + expect(screen.queryByText('On Another Day')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-SIDEBAR-053: without a day open it is the whole trip again', () => { + const dayOne = buildPlace({ id: 91, name: 'On This Day' }); + const dayTwo = buildPlace({ id: 92, name: 'On Another Day' }); + const assignments = { + '1': [buildAssignment({ place: dayOne, day_id: 1 })], + '2': [buildAssignment({ place: dayTwo, day_id: 2 })], + }; + seedStore(useTripStore, { placesFilter: 'planned' }); + render( + , + ); + + expect(screen.getByText('On This Day')).toBeInTheDocument(); + expect(screen.getByText('On Another Day')).toBeInTheDocument(); + }); + + it('FE-PLANNER-SIDEBAR-054: the note only appears while the day is actually narrowing', () => { + const today = buildPlace({ id: 91, name: 'On This Day' }); + const assignments = { '1': [buildAssignment({ place: today, day_id: 1 })] }; + const onClearSelectedDay = vi.fn(); + seedStore(useTripStore, { placesFilter: 'planned' }); + const { rerender } = render( + , + ); + expect(screen.getByText('Showing the open day only')).toBeInTheDocument(); + + // Dismissing it asks for the day to be closed rather than changing the filter. + fireEvent.click(screen.getByLabelText('Show the whole trip')); + expect(onClearSelectedDay).toHaveBeenCalledTimes(1); + + // No day open, nothing narrowed, no note. + rerender( + , + ); + expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-SIDEBAR-055: "Unplanned" stays trip-wide while a day is open', () => { + // A place assigned to some other day is planned, whichever day happens to be open, + // so it must not reappear in the unplanned pool. + const otherDay = buildPlace({ id: 92, name: 'On Another Day' }); + const loose = buildPlace({ id: 93, name: 'Not Planned At All' }); + const assignments = { '2': [buildAssignment({ place: otherDay, day_id: 2 })] }; + seedStore(useTripStore, { placesFilter: 'unplanned' }); + render( + , + ); + + expect(screen.getByText('Not Planned At All')).toBeInTheDocument(); + expect(screen.queryByText('On Another Day')).not.toBeInTheDocument(); + expect(screen.queryByText('Showing the open day only')).not.toBeInTheDocument(); + }); }); // ── Search ──────────────────────────────────────────────────────────────────── diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 6fb4eee5c..72d03dd7e 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -1,4 +1,4 @@ -import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus } from 'lucide-react' +import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus, CalendarDays } from 'lucide-react' import { getCategoryIcon } from '../shared/categoryIcons' import Tooltip from '../shared/Tooltip' import { useElementSize } from '../../hooks/useElementSize' @@ -32,7 +32,7 @@ export function PlacesDropOverlay({ t }: SidebarState) { export function PlacesHeader(S: SidebarState) { const { canEditPlaces, onAddPlace, onAddPlaceToSelectedDay, selectedDayId, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, - places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks, + places, categories, categoryFilters, search, setSearch, plannedIds, plannedFilterIds, dayScoped, onClearSelectedDay, hasTracks, filter, setFilter, setSelectedIds, selectMode, setSelectMode, catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFilters, ratingFilter, setRatingFilter, @@ -163,7 +163,11 @@ export function PlacesHeader(S: SidebarState) { const counts = { all: baseFiltered.length, unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length, - planned: baseFiltered.filter(p => plannedIds.has(p.id)).length, + // While a day is open this counts that day's plan, the same set the list and + // the map show. Counting the whole trip here is what made the tab read 55 + // beside five pins, with nothing to say the two were answering different + // questions. + planned: baseFiltered.filter(p => plannedFilterIds.has(p.id)).length, tracks: baseFiltered.filter(p => p.route_geometry).length, } const tabs = ([ @@ -211,6 +215,42 @@ export function PlacesHeader(S: SidebarState) { ) })()} + {/* Says out loud what the count above already narrowed to. + The map has followed the open day on this filter since #2024, and until now + nothing anywhere said so: the pool read 55, the map drew five, and the honest + conclusion was that the map was broken. It sits under the tabs rather than on + the map because a chip over the canvas is unreachable on a phone, which is + where this was reported from. */} + {dayScoped && ( +
+ + + {t('places.dayScoped')} + + {onClearSelectedDay && ( + + )} +
+ )} + {/* Suchfeld */}
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index 63f808ca0..4b1e62b6b 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -15,7 +15,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget' import type { Place, Category, Day, AssignmentsMap } from '../../types' import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' import { safeHttpUrl } from '../../utils/safeUrl' -import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces' +import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces' /** Stable identity — a fresh [] default would invalidate the planned memo on every render. */ const NO_ACCOMMODATIONS: PlannedAccommodation[] = [] @@ -40,6 +40,12 @@ export interface PlacesSidebarProps { * through the day picker. */ onAddPlaceToSelectedDay?: () => void + /** + * Close the open day, from the note that says the pool is showing only that day. + * Absent leaves the note without its dismiss, which is what the mobile day picker + * wants: there the day is closed by the picker itself. + */ + onClearSelectedDay?: () => void onAssignToDay: (placeId: number, dayId: number) => void onEditPlace: (place: Place) => void onDeletePlace: (placeId: number) => void @@ -60,7 +66,7 @@ export interface PlacesSidebarProps { */ export function usePlacesSidebar(props: PlacesSidebarProps) { const { - tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS, + tripId, places, assignments, selectedDayId, days, accommodations = NO_ACCOMMODATIONS, pushUndo, initialScrollTop, onScrollTopChange, } = props const { t } = useTranslation() @@ -238,10 +244,30 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { [assignments, accommodations, reservations], ) + /** + * What "planned" means while a day is open: that day's plan, not the whole trip's. + * + * The map has narrowed to the selected day since #2024, and the list did not, which is + * how a trip with 55 planned places showed 55 in the pool and five pins on the map with + * nothing to explain the gap — read, reasonably, as the map being broken. The list now + * follows the map, and `dayScoped` tells the header to say so. + * + * Only "planned" narrows. "Unplanned" stays trip-wide on purpose: a place assigned to + * some other day is planned, whichever day happens to be open. + */ + const plannedInDayIds = useMemo( + () => (selectedDayId + ? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations, reservations }) + : null), + [selectedDayId, days, assignments, accommodations, reservations], + ) + const plannedFilterIds = plannedInDayIds ?? plannedIds + const dayScoped = filter === 'planned' && plannedInDayIds !== null + const filtered = useMemo(() => { const list = places.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false - if (filter === 'planned' && !plannedIds.has(p.id)) return false + if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false if (filter === 'tracks' && !p.route_geometry) return false if (categoryFilters.size > 0) { if (p.category_id == null) { @@ -254,7 +280,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { return true }) return list - }, [places, filter, categoryFilters, search, plannedIds, ratingFilter]) + }, [places, filter, categoryFilters, search, plannedIds, plannedFilterIds, ratingFilter]) const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => { if (element) { @@ -322,7 +348,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { markSelectionVisited, markVisitedBusy, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, - hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu, + hasTracks, plannedIds, plannedFilterIds, dayScoped, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu, } } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index c531c8ad6..f13264469 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -685,6 +685,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} + onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true) }} @@ -852,7 +853,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{mobileSidebarOpen === 'left' ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> - : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> + : handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 3b4a14510..4c60cfe2d 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'الكل', 'places.unplanned': 'غير مخطط', 'places.planned': 'مخطط لها', + 'places.dayScoped': 'يظهر اليوم المفتوح فقط', + 'places.dayScopedClear': 'عرض الرحلة كاملة', 'places.filterTracks': 'المسارات', 'places.filterByRating': 'تصفية حسب التقييم', 'places.yourRating': 'تقييمك', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 26b45eef9..219593416 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Todos', 'places.unplanned': 'Não planejados', 'places.planned': 'Planejados', + 'places.dayScoped': 'Mostrando apenas o dia aberto', + 'places.dayScopedClear': 'Mostrar a viagem inteira', 'places.filterTracks': 'Trilhas', 'places.filterByRating': 'Filtrar por avaliação', 'places.yourRating': 'Sua avaliação', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 8f896097e..7be892b52 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -46,6 +46,8 @@ const places: TranslationStrings = { 'places.all': 'Tot', 'places.unplanned': 'Sense planificar', 'places.planned': 'Planificats', + 'places.dayScoped': 'Es mostra només el dia obert', + 'places.dayScopedClear': 'Mostra tot el viatge', 'places.filterTracks': 'Rutes', 'places.filterByRating': 'Filtra per valoració', 'places.yourRating': 'La teva valoració', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 502e44162..bee0ee1f5 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Vše', 'places.unplanned': 'Nezařazené', 'places.planned': 'Naplánované', + 'places.dayScoped': 'Zobrazuje se jen otevřený den', + 'places.dayScopedClear': 'Zobrazit celou cestu', 'places.filterTracks': 'Trasy', 'places.filterByRating': 'Filtrovat podle hodnocení', 'places.yourRating': 'Tvé hodnocení', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 32915916e..805000187 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Alle', 'places.unplanned': 'Ungeplant', 'places.planned': 'Geplant', + 'places.dayScoped': 'Nur der geöffnete Tag wird angezeigt', + 'places.dayScopedClear': 'Ganze Reise anzeigen', 'places.filterTracks': 'Tracks', 'places.filterByRating': 'Nach Bewertung filtern', 'places.yourRating': 'Deine Bewertung', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 1f7d44850..96d87da19 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -48,6 +48,9 @@ const places: TranslationStrings = { 'places.all': 'All', 'places.unplanned': 'Unplanned', 'places.planned': 'Planned', + /** Shown under the filter tabs while an open day narrows both the list and the map. */ + 'places.dayScoped': 'Showing the open day only', + 'places.dayScopedClear': 'Show the whole trip', 'places.filterTracks': 'Tracks', 'places.filterByRating': 'Filter by rating', 'places.yourRating': 'Your rating', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 90e19e739..8f6faadd5 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Todo', 'places.unplanned': 'Sin planificar', 'places.planned': 'Planificados', + 'places.dayScoped': 'Se muestra solo el día abierto', + 'places.dayScopedClear': 'Mostrar todo el viaje', 'places.filterTracks': 'Rutas', 'places.filterByRating': 'Filtrar por valoración', 'places.yourRating': 'Tu valoración', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 3861cad4c..5fd06d99b 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Tous', 'places.unplanned': 'Non planifiés', 'places.planned': 'Planifiés', + 'places.dayScoped': 'Seul le jour ouvert est affiché', + 'places.dayScopedClear': 'Afficher tout le voyage', 'places.filterTracks': 'Traces', 'places.filterByRating': 'Filtrer par note', 'places.yourRating': 'Ta note', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 979e69895..1e2b361f4 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Όλα', 'places.unplanned': 'Μη προγραμματισμένα', 'places.planned': 'Προγραμματισμένα', + 'places.dayScoped': 'Εμφανίζεται μόνο η ανοιχτή ημέρα', + 'places.dayScopedClear': 'Εμφάνιση όλου του ταξιδιού', 'places.filterTracks': 'Ίχνη', 'places.filterByRating': 'Φιλτράρισμα κατά βαθμολογία', 'places.yourRating': 'Η βαθμολογία σου', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index ba00b2672..3d6726941 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Összes', 'places.unplanned': 'Nem tervezett', 'places.planned': 'Tervezett', + 'places.dayScoped': 'Csak a megnyitott nap látszik', + 'places.dayScopedClear': 'Teljes utazás megjelenítése', 'places.filterTracks': 'Nyomvonalak', 'places.filterByRating': 'Szűrés értékelés szerint', 'places.yourRating': 'Az értékelésed', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 5e588669b..8c4c25d4b 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -47,6 +47,8 @@ const places: TranslationStrings = { 'places.all': 'Semua', 'places.unplanned': 'Belum direncanakan', 'places.planned': 'Direncanakan', + 'places.dayScoped': 'Hanya menampilkan hari yang dibuka', + 'places.dayScopedClear': 'Tampilkan seluruh perjalanan', 'places.filterTracks': 'Trek', 'places.filterByRating': 'Saring menurut penilaian', 'places.yourRating': 'Rating kamu', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index 70a0983d0..0841c7f53 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Tutti', 'places.unplanned': 'Non pianificati', 'places.planned': 'Pianificati', + 'places.dayScoped': 'Viene mostrato solo il giorno aperto', + 'places.dayScopedClear': 'Mostra tutto il viaggio', 'places.filterTracks': 'Tracce', 'places.filterByRating': 'Filtra per valutazione', 'places.yourRating': 'La tua valutazione', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 65501aa80..ea876eabc 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'すべて', 'places.unplanned': '未計画', 'places.planned': '計画済み', + 'places.dayScoped': '開いている日のみ表示中', + 'places.dayScopedClear': '旅行全体を表示', 'places.filterTracks': 'トラック', 'places.filterByRating': '評価で絞り込む', 'places.yourRating': 'あなたの評価', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index f66bfc94c..51af20741 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -47,6 +47,8 @@ const places: TranslationStrings = { 'places.all': '전체', 'places.unplanned': '미계획', 'places.planned': '계획됨', + 'places.dayScoped': '열린 날짜만 표시 중', + 'places.dayScopedClear': '전체 여행 보기', 'places.filterTracks': '트랙', 'places.filterByRating': '평점으로 필터링', 'places.yourRating': '내 평점', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 2c0ca256b..84e277a85 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Alle', 'places.unplanned': 'Ongepland', 'places.planned': 'Gepland', + 'places.dayScoped': 'Alleen de geopende dag wordt getoond', + 'places.dayScopedClear': 'Hele reis tonen', 'places.filterTracks': 'Tracks', 'places.filterByRating': 'Filteren op beoordeling', 'places.yourRating': 'Jouw beoordeling', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 0c5bccef9..ca24a6fcf 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -38,6 +38,8 @@ const places: TranslationStrings = { 'places.all': 'Wszystkie', 'places.unplanned': 'Niezaplanowane', 'places.planned': 'Zaplanowane', + 'places.dayScoped': 'Widoczny tylko otwarty dzień', + 'places.dayScopedClear': 'Pokaż całą podróż', 'places.filterTracks': 'Trasy', 'places.filterByRating': 'Filtruj według oceny', 'places.yourRating': 'Twoja ocena', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 48d2c32c7..c72a25f10 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Все', 'places.unplanned': 'Незапланированные', 'places.planned': 'Запланированные', + 'places.dayScoped': 'Показан только открытый день', + 'places.dayScopedClear': 'Показать всю поездку', 'places.filterTracks': 'Треки', 'places.filterByRating': 'Фильтр по оценке', 'places.yourRating': 'Твоя оценка', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 0f05bd19b..32179f3fc 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Alla', 'places.unplanned': 'Oplanerat', 'places.planned': 'Planerat', + 'places.dayScoped': 'Visar bara den öppna dagen', + 'places.dayScopedClear': 'Visa hela resan', 'places.filterTracks': 'Spår', 'places.filterByRating': 'Filtrera efter betyg', 'places.yourRating': 'Ditt betyg', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index f68a32f1a..daf8f9ff7 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -50,6 +50,8 @@ const places: TranslationStrings = { 'places.all': 'Tüm', 'places.unplanned': 'Planlanmamış', 'places.planned': 'Planlanmış', + 'places.dayScoped': 'Yalnızca açık gün gösteriliyor', + 'places.dayScopedClear': 'Tüm yolculuğu göster', 'places.filterTracks': 'Parçalar', 'places.filterByRating': 'Puana göre filtrele', 'places.yourRating': 'Senin puanın', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 35ecee769..e079c550c 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Усі', 'places.unplanned': 'Незаплановані', 'places.planned': 'Заплановані', + 'places.dayScoped': 'Показано лише відкритий день', + 'places.dayScopedClear': 'Показати всю подорож', 'places.filterTracks': 'Треки', 'places.filterByRating': 'Фільтр за оцінкою', 'places.yourRating': 'Твоя оцінка', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 118a6f6d5..fd1fd0838 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -48,6 +48,8 @@ const places: TranslationStrings = { 'places.all': 'Tất cả', 'places.unplanned': 'Không có kế hoạch', 'places.planned': 'Có kế hoạch', + 'places.dayScoped': 'Chỉ hiển thị ngày đang mở', + 'places.dayScopedClear': 'Hiện toàn bộ chuyến đi', 'places.filterTracks': 'Bài hát', 'places.filterByRating': 'Lọc theo đánh giá', 'places.yourRating': 'Đánh giá của bạn', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index e01c13406..3301544f0 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -47,6 +47,8 @@ const places: TranslationStrings = { 'places.all': '全部', 'places.unplanned': '未規劃', 'places.planned': '已規劃', + 'places.dayScoped': '僅顯示目前開啟的日期', + 'places.dayScopedClear': '顯示整趟行程', 'places.filterTracks': '路線', 'places.filterByRating': '依評分篩選', 'places.yourRating': '你的評分', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 8b8ffc583..d9e9cd9b9 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -47,6 +47,8 @@ const places: TranslationStrings = { 'places.all': '全部', 'places.unplanned': '未规划', 'places.planned': '已规划', + 'places.dayScoped': '仅显示当前打开的日期', + 'places.dayScopedClear': '显示整个行程', 'places.filterTracks': '路线', 'places.filterByRating': '按评分筛选', 'places.yourRating': '你的评分', From 00cac02665019b5217ef473d6c58542256a184c4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 15:56:37 +0200 Subject: [PATCH 08/29] feat(maps): satellite on the GL maps too, not just Leaflet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The imagery toggle has been there for a while and only ever worked on Leaflet, so anyone on MapLibre or Mapbox — which is the default since the vector basemap landed — had a switch they could not see and a view they could not reach. Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a style with dozens of layers in it. So the same tokenless ESRI source goes on as a raster layer of its own, inserted before the first layer TREK draws, which leaves the route, the pins and the tracks on top and legible. Switching back hides it rather than tearing it down, so going to and fro does not re-fetch a tile. Built on demand rather than once at load, because a basemap change rebuilds the style from scratch and takes every source with it; the styledata event is subscribed for the same reason. The switch reads the setting the Leaflet map already stores, so the choice survives an instance changing renderer, and it sits in the same corner with the same offsets. --- client/src/components/Map/MapViewGL.test.tsx | 64 ++++++++++++++ client/src/components/Map/MapViewGL.tsx | 89 +++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 784c60060..3f8ffe0f7 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1955,4 +1955,68 @@ describe('MapViewGL', () => { // in the wrong place. expect(layer.firstElementChild).not.toBe(handle) }) + // ── Satellite ─────────────────────────────────────────────────────────────── + // + // Leaflet has had the imagery for a while and swaps its whole tile layer for it. + // A GL map cannot do that, because its basemap is a style with dozens of layers, + // so the imagery goes on as a raster layer under everything TREK draws. + + it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, + } as never) + glMap.getStyle.mockReturnValue({ + layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }], + }) + + render() + await flushFrames() + + expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ + type: 'raster', + tiles: [expect.stringContaining('arcgisonline.com')], + })) + // Anchored before the route, or the imagery would be painted over it. + expect(glMap.addLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), + 'trip-route', + ) + }) + + it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, + } as never) + + render() + await flushFrames() + + expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) + }) + + it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, + } as never) + glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) + // Present from the first pass on, the way the real map reports it afterwards. + glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null)) + glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null)) + + const { rerender } = render() + await flushFrames() + + act(() => { + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, + } as never) + }) + rerender() + await flushFrames() + + expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none') + }) }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 72005e7c4..015e9d604 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' +import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { isCustomPlaceImage, photoCacheKey } from './placePhoto' @@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' -import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' +import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport' function categoryIconSvg(iconName: string | null | undefined, size: number): string { @@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle' const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count' const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit' const GPX_HIT_LAYER_ID = 'trip-gpx-hit' +/** + * The satellite base layer, the GL twin of the Leaflet one. + * + * Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the + * basemap is a style with dozens of layers in it. So the imagery goes on as a raster + * layer of its own, drawn over the style and under everything TREK adds — the route, + * the pins and the tracks stay on top and stay legible, which is the same order the + * Leaflet map ends up with. + * + * Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key. + */ +const SATELLITE_SOURCE_ID = 'trip-satellite' +const SATELLITE_LAYER_ID = 'trip-satellite-raster' +/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */ +const OWN_LAYER_PREFIXES = ['trip-', 'trek-'] type PlaceWithCoords = Place & { lat: number; lng: number } @@ -233,6 +249,44 @@ function addPlaceClusterLayers(map: any): void { }) } +/** + * Puts the imagery on the map, or takes it off again. + * + * Built on demand rather than once at load: a style change drops every source the map + * had, and a hot reload does the same. Guarded on its own absence, so the usual pass is + * a no-op and only the visibility flips. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function applySatellite(map: any, on: boolean): void { + try { + if (!map.isStyleLoaded?.()) return + if (!map.getSource(SATELLITE_SOURCE_ID)) { + if (!on) return // Nothing to build while it is switched off. + map.addSource(SATELLITE_SOURCE_ID, { + type: 'raster', + tiles: [SATELLITE_TILE_URL], + tileSize: 256, + maxzoom: SATELLITE_TILE_MAXZOOM, + attribution: SATELLITE_TILE_ATTRIBUTION, + }) + // Under the first thing TREK draws, over everything the basemap style draws. + // Without the anchor the imagery lands on top and buries the route. + const layers = map.getStyle?.()?.layers ?? [] + const firstOwn = layers.find((l: { id: string }) => + OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix))) + map.addLayer({ + id: SATELLITE_LAYER_ID, + type: 'raster', + source: SATELLITE_SOURCE_ID, + paint: { 'raster-opacity': 1 }, + }, firstOwn?.id) + } + if (map.getLayer(SATELLITE_LAYER_ID)) { + map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none') + } + } catch { /* a style that refuses the layer keeps the plain basemap */ } +} + function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { // A stop that interrupts the drive gets its own small disc, decided before the photo // branch: the brand logo a fuel search comes back with is exactly what this replaces. @@ -594,6 +648,15 @@ export function MapViewGL({ const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') + // The same stored choice the Leaflet map reads, so the two renderers agree. + const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const updateSetting = useSettingsStore(s => s.updateSetting) + const isSatellite = baseLayer === 'satellite' + const toggleBaseLayer = useCallback(() => { + // The store flips synchronously, so the map switches even offline; a failed save + // is logged there rather than blocking the switch. + updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) + }, [isSatellite, updateSetting]) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true @@ -2058,6 +2121,18 @@ export function MapViewGL({ else map.once('load', apply) }, [userPosition, trackingMode, glProvider]) + // Satellite, the same setting the Leaflet map reads, so switching it on one renderer + // and reloading into the other keeps the choice. `styledata` is subscribed because a + // basemap change rebuilds the style from scratch and takes the imagery with it. + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + const apply = () => applySatellite(map, isSatellite) + apply() + map.on('styledata', apply) + return () => { map.off('styledata', apply) } + }, [isSatellite, mapReady, glProvider]) + if (!isMapLibre && !mapboxToken) { return (
@@ -2093,6 +2168,18 @@ export function MapViewGL({ bottomOffset={buttonBottom as unknown as number} /> )} + {/* Same pill, same corner, same offsets as the Leaflet map: the switch should not + move when an instance changes renderer. The day panel is a centred card on the + desktop map and never reaches the pill, but on a phone it is full width and + does, which is why the lift is only there. */} +
+ +
{/* Hover tooltip — cursor-following name/category/address card, identical to the Leaflet map's overlay (no anchored popup, no photo). */} {!hoverDisabled && hoverPlace && hoverPos && !isMobile && ( From fab3d2bdf8b3a456b9aa20d026f379764f8095f6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 15:59:06 +0200 Subject: [PATCH 09/29] fix(maps): ask the OpenStreetMap layer on the autocomplete path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing "Tokio station" from Tokyo suggested a weigh station in Ritzville and a station in Tuxtla Gutiérrez, and typing "Tokyo Station" put Japan in seventh place behind Markham, Arlington, Calgary, Milano and Melbourne. The station itself was never in the list. The service has had the answer all along. Its OpenStreetMap layer carries 14.6M travel places with every name form each is known under, including the exonyms a traveller actually types, and it is not the same data as the index: Overture holds businesses, so a search for a station finds the shops inside it. The layer returns "Tokio Hauptbahnhof" for the German spelling and 東京駅 for the Japanese. It was never asked. `sources` defaults to the index alone and trekPlacesSearch never sent the parameter, so no caller here has ever seen a layer row. Sent on the autocomplete path only. The explicit search asks Nominatim in parallel and would get the same OpenStreetMap places twice; this path asks nobody else, because Nominatim's usage policy names autocomplete as unacceptable use. So the layer is not a second opinion here, it is the only place those names live. A layer row has a different shape, and that shape is why this is more than one parameter. It has no `gers` and no address block, and its id reads `osm:node/9712313` where this file resolves `node:9712313`. Passing it through untranslated would hand the client an id getPlaceDetails does not know, and the failure would land after the user had already picked the suggestion. The secondary line falls back to the local name, which is what is written on the building and more use under a translated label than an empty line. Measured against the service before the change, same query, same coordinates: "Tokio station" index only -> Tokio Weigh Station, Ritzville US with layer -> Tokio Hauptbahnhof in second place "東京駅" index only -> six restaurants inside the station with layer -> 東京駅 itself in the list --- server/src/nest/maps/maps.service.ts | 48 ++++++++++++--- server/src/nest/maps/trek-places.client.ts | 61 ++++++++++++++++++- .../tests/unit/nest/maps.autocomplete.test.ts | 56 ++++++++++++++++- 3 files changed, 153 insertions(+), 12 deletions(-) diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 084acffd3..a5eaa085a 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -18,6 +18,9 @@ import { DatabaseService } from '../database/database.service'; import { nominatimFetch, type GeoLane } from '../geo/nominatim.client'; import { trekPlacesSearch, + indexHitsOnly, + isOsmHit, + osmPlaceId, trekPlacesById, trekPlacesArea, trekPlacesNearby, @@ -1915,7 +1918,7 @@ export class MapsService { lat: locationBias?.lat, lng: locationBias?.lng, limit: 10, - }).catch((err: unknown) => { + }).then(indexHitsOnly).catch((err: unknown) => { console.warn('TREK Places search failed, falling back:', (err as Error).message); return [] as TrekPlace[]; }), @@ -2036,14 +2039,45 @@ export class MapsService { lng: (locationBias.low.lng + locationBias.high.lng) / 2, } : undefined; - const found = await trekPlacesSearch(input, { lat: centre?.lat, lng: centre?.lng, limit: 8 }); + // Both layers here, unlike the explicit search above. That path asks + // Nominatim in parallel and would get the same OpenStreetMap places + // twice; this one asks nobody else, because Nominatim's usage policy + // names autocomplete as unacceptable use. So the layer is not a second + // opinion here, it is the only place the missing names live. + // + // Measured on the case that surfaced it: "Tokio station" typed from + // Tokyo returned a weigh station in Ritzville and a station in Mexico + // from the index alone, and "Tokio Hauptbahnhof" in second place with + // the layer. The index holds businesses; stations, temples and bridges + // are in OpenStreetMap, and so is every exonym a traveller types. + const found = await trekPlacesSearch(input, { + lat: centre?.lat, + lng: centre?.lng, + limit: 8, + sources: 'index,osm', + }); if (found.length > 0) { return { - suggestions: found.map(p => ({ - placeId: `gers:${p.gers}`, - mainText: p.name, - secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '), - })), + suggestions: found.map(p => + isOsmHit(p) + ? { + // The service's own id form, translated into the one this + // file already resolves. Leaving it as `osm:node/123` would + // hand the client an id getPlaceDetails does not know, and + // the failure would land after the user had picked it. + placeId: osmPlaceId(p), + mainText: p.name, + // The layer carries no address. The local name is what the + // place is called on the spot, which is more use under a + // translated label than an empty line. + secondaryText: p.local_name && p.local_name !== p.name ? p.local_name : '', + } + : { + placeId: `gers:${p.gers}`, + mainText: p.name, + secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '), + }, + ), source: 'trek-places', }; } diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts index 5fb95a47e..5881aea09 100644 --- a/server/src/nest/maps/trek-places.client.ts +++ b/server/src/nest/maps/trek-places.client.ts @@ -228,19 +228,74 @@ async function readCapped(res: Response, max: number): Promise { return Buffer.concat(chunks).toString('utf8'); } +/** + * A row from the service's OpenStreetMap layer, which answers alongside the + * index when `sources` asks for it. + * + * A different shape from TrekPlace, and that is the point rather than an + * oversight: the layer carries every name form a place is known under, which is + * what the index does not have. `name` is the one that matched the query and + * `local_name` the one written on the building, so a German searching "Tokio + * Hauptbahnhof" gets that as the label and 東京駅丸の内駅舎 underneath. + * + * It has no address block. OpenStreetMap does not carry one per node, and + * inventing an empty one here would let a caller read `address.locality` and + * quietly get nothing. + */ +export interface TrekOsmPlace { + /** Always `osm:/`, e.g. `osm:node/9712313`. */ + id: string; + osm_type: 'node' | 'way' | 'relation'; + osm_id: number; + name: string; + local_name: string | null; + lat: number; + lng: number; + category: string | null; + source: 'openstreetmap'; + distance_km?: number; +} + +export type TrekSearchHit = TrekPlace | TrekOsmPlace; + +export const isOsmHit = (hit: TrekSearchHit): hit is TrekOsmPlace => + hit.source === 'openstreetmap'; + +/** + * Turn the layer's id into the form the rest of this service already speaks. + * + * `osm:node/9712313` becomes `node:9712313`, which is what OSM_PLACE_ID matches + * and what getPlaceDetails resolves through the existing OpenStreetMap path. + * Without this the suggestion would carry an id nothing downstream recognises, + * and picking it would fail after the user had already chosen it. + */ +export function osmPlaceId(hit: TrekOsmPlace): string { + return `${hit.osm_type}:${hit.osm_id}`; +} + export async function trekPlacesSearch( query: string, - opts: { lat?: number; lng?: number; limit?: number } = {}, -): Promise { - const body = await getJson('/v1/search', { + opts: { lat?: number; lng?: number; limit?: number; sources?: string } = {}, +): Promise { + const body = await getJson<{ results?: TrekSearchHit[] }>('/v1/search', { q: query, lat: opts.lat, lng: opts.lng, limit: opts.limit ?? 10, + // Left off unless asked for, so the service applies its own default. That + // default is the index alone, which is what every existing caller here + // wants: the explicit search path already asks Nominatim in parallel, and + // adding the layer there would return the same OpenStreetMap places twice. + sources: opts.sources, }); return Array.isArray(body.results) ? body.results : []; } +/** The index-only rows of a mixed result, for callers that need Overture fields. */ +export function indexHitsOnly(hits: TrekSearchHit[]): TrekPlace[] { + return hits.filter((h): h is TrekPlace => !isOsmHit(h)); +} + /** * The planner's category pills, mapped onto Overture categories. * diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index 719d2282c..5f4fcd71c 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -54,6 +54,24 @@ const hit = (over: Record = {}) => ({ ...over, }); +/** + * A row from the service's OpenStreetMap layer. A different shape from the + * index rows above, which is the whole point of the two tests below: no `gers`, + * no address block, and an id in the service's own `osm:/` form. + */ +const osmHit = (over: Record = {}) => ({ + id: 'osm:node/9712313', + osm_type: 'node', + osm_id: 9712313, + name: 'Tokio Hauptbahnhof', + local_name: '東京駅丸の内駅舎', + lat: 35.6811816, + lng: 139.76598265, + category: 'sehenswuerdigkeit', + source: 'openstreetmap', + ...over, +}); + /** * `enabled` drives the admin kill switch, which is read straight off * app_settings; an unset row reads as on, because the switch is fail-open. @@ -111,7 +129,8 @@ describe('MapsService.autocompletePlaces', () => { }); // Eight, not ten: the suggestion list is what a person reads while typing. - expect(mockSearch).toHaveBeenCalledWith(INPUT, { lat: 54.1, lng: 12.2, limit: 8 }); + expect(mockSearch).toHaveBeenCalledWith(INPUT, + { lat: 54.1, lng: 12.2, limit: 8, sources: 'index,osm' }); }); it('MAPS-AUTO-004: without a bias the index is asked without coordinates rather than with zeroes', async () => { @@ -122,7 +141,8 @@ describe('MapsService.autocompletePlaces', () => { // A 0/0 bias is a point in the Atlantic, and the index treats a bias as // permission to relax the match — which is how the shops around a landmark // start outranking the landmark. - expect(mockSearch).toHaveBeenCalledWith(INPUT, { lat: undefined, lng: undefined, limit: 8 }); + expect(mockSearch).toHaveBeenCalledWith(INPUT, + { lat: undefined, lng: undefined, limit: 8, sources: 'index,osm' }); }); it('MAPS-AUTO-005: an index that found nothing falls through instead of answering empty', async () => { @@ -144,6 +164,38 @@ describe('MapsService.autocompletePlaces', () => { expect(result.source).not.toBe('trek-places'); }); + it('MAPS-AUTO-008: a hit from the OpenStreetMap layer carries an id the details lookup can read', async () => { + mockSearch.mockResolvedValue([osmHit()]); + + const { suggestions } = await make().autocompletePlaces(1, INPUT); + + // `osm:node/9712313` is the service's form; this file resolves `node:123`. + // Passing the service's form through would hand the client an id nothing + // downstream recognises, and it would fail after the user had picked it. + expect(suggestions[0].placeId).toBe('node:9712313'); + expect(suggestions[0].mainText).toBe('Tokio Hauptbahnhof'); + // The layer has no address. The name written on the building is more use + // under a translated label than an empty second line. + expect(suggestions[0].secondaryText).toBe('東京駅丸の内駅舎'); + }); + + it('MAPS-AUTO-009: index and layer hits keep their own id form in one list', async () => { + mockSearch.mockResolvedValue([hit(), osmHit(), hit({ gers: 'def-456' })]); + + const { suggestions } = await make().autocompletePlaces(1, INPUT); + + expect(suggestions.map(s => s.placeId)).toEqual([ + 'gers:abc-123', + 'node:9712313', + 'gers:def-456', + ]); + // A local name equal to the label would be a repeated line, not a hint. + const same = osmHit({ local_name: 'Tokio Hauptbahnhof' }); + mockSearch.mockResolvedValue([same]); + const second = await make().autocompletePlaces(1, INPUT); + expect(second.suggestions[0].secondaryText).toBe(''); + }); + it('MAPS-AUTO-007: the keystroke never leaves for the index while the admin has it off', async () => { mockSearch.mockResolvedValue([hit()]); From 2f71549413f9246c9ba0e6ba12be5b7c20f53003 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 16:56:00 +0200 Subject: [PATCH 10/29] fix(maps): let the imagery through the CSP, so the GL switch does something The satellite toggle has worked on Leaflet for as long as it has existed and did nothing at all on a GL map, which is the default renderer since the vector basemap landed. Not for want of the layer: connect-src never named server.arcgisonline.com. Leaflet asks for a tile as an , and img-src waves every https: host through, so the gap could sit there unnoticed for as long as Leaflet was the only renderer that showed the imagery. A GL map reads the raster through fetch to hand it to WebGL, and that lands on connect-src instead, where the host is missing. The browser refuses it in the document, which is the worst shape a failure can take here: the switch flips, the layer goes on, the source is there, and no tile ever arrives. Nothing in the app can see it either, because a CSP refusal is not an error the fetch can catch. The prefetcher asks for the same tiles the same way, with mode 'no-cors', which relaxes CORS and not CSP, so the imagery was never cached for offline use either. Same shape as #2180, which added the other two raster presets for exactly this reason and did not reach this host. --- server/src/middleware/globalMiddleware.ts | 7 +++++++ .../middleware/globalMiddleware.csp.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 06665600f..eeb805f59 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -235,6 +235,13 @@ export function applyGlobalMiddleware( // document and never reaches the Service Worker that would cache it // (#2180). routing.openstreetmap.de below is a different host. "https://tile.openstreetmap.de", "https://tiles.stadiamaps.com", + // The imagery host, for the same reason and one more. Leaflet fetches a tile + // as an , which img-src's blanket `https:` waves through, so the satellite + // view worked on Leaflet with this host missing. A GL map reads the raster + // through fetch to hand it to WebGL, and the prefetch does too, so both were + // refused here while nothing in the app could see it: the switch flipped, the + // layer went on, and no tile ever arrived (#2307). + "https://server.arcgisonline.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 8b768d0bf..ea1e14dea 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -52,6 +52,25 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { }); }); +describe('global CSP: the imagery host (#2307)', () => { + it('allows server.arcgisonline.com, which a GL map reaches through fetch', async () => { + // The gap hid behind Leaflet for as long as Leaflet was the only renderer to + // show the imagery: it loads a tile as an , and img-src allows `https:` + // outright. A GL map reads the raster through fetch to hand it to WebGL, so + // it lands on connect-src instead and was refused with nothing to see for it. + expect(await connectSrcSources()).toContain('https://server.arcgisonline.com'); + }); + + it('names the apex host, because img-src is what used to carry it', async () => { + // img-src keeps its blanket `https:`, which is why Leaflet never noticed. The + // fix belongs on connect-src alone; widening img-src further would buy nothing + // and widening connect-src to a wildcard would not match the apex anyway. + const sources = await connectSrcSources(); + expect(sources).not.toContain('https://*.arcgisonline.com'); + expect(await directiveSources('img-src')).toContain('https:'); + }); +}); + describe('global CSP: script-src', () => { it("allows 'wasm-unsafe-eval' so the WASM decoders keep running", async () => { expect(await directiveSources('script-src')).toContain("'wasm-unsafe-eval'"); From 7ba7338f5f8448c047ea0676db549ed4e7c7cc51 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 17:24:05 +0200 Subject: [PATCH 11/29] fix(maps): sag bei jedem Vorschlag, welcher Index geantwortet hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit die Tipp-Suche beide Schichten fragt, ist ihre Liste gemischt — die Antwort nennt aber weiterhin EINE Quelle fuer die ganze Liste, und die heisst 'trek-places'. Der Client beschriftet damit jede Zeile, auch die aus OpenStreetMap. Wer tippt, liest also acht Mal "TREK" und schliesst daraus, die OSM-Schicht antworte gar nicht. Sie antwortet, sie steht nur unter falschem Namen da. Auf der Suchstrecke daneben stimmt es laengst: dort traegt jeder Ort sein eigenes `source`, und genau deshalb liest sich diese Liste richtig. Also traegt es jetzt auch die Zeile. Das Feld ist optional, weil Google und der Nominatim-Rueckfall je aus einer Quelle antworten; eine Zeile ohne eigene Angabe faellt weiter auf den Namen der Liste zurueck. Dazu die Koordinaten, die der Vorschlag ohnehin schon kennt. Bisher wurden sie weggeworfen, und wenn der Details-Aufruf nicht antwortete — Kill-Switch, ein ueberlasteter Overpass-Spiegel, irgendein Fehler oben — baute der Client eine Textsuche aus Name und zweiter Zeile. Bei einer OSM-Zeile ist die zweite Zeile aber keine Adresse, sondern der ortsuebliche Name: gesucht wurde dann nach "Tokio Hauptbahnhof, 東京駅丸の内駅舎", und dessen erstes Ergebnis wurde kommentarlos als der Ort genommen, den der Nutzer angeklickt hatte. Jetzt nimmt der Rueckfall den Punkt, den die Zeile mitbringt. Und das Abzeichen gibt es endlich auch mobil. Die Marke und ihre Tabelle liegen dafuer in client/src/utils/placeSource.ts, weil beide Oberflaechen dieselben Listen zeigen und ein Zeichen, das nur auf einer davon steht, schlimmer ist als keines. Was hier NICHT drin ist, obwohl es dazugehoert: dass der richtige Bahnhof in der Liste steht. Das liegt am Dienst, nicht an TREK, und ist dort gemessen und behoben — Rangfolge nach Art des Ortes plus die Verkehrsknoten, die der OSM-Schicht ganz fehlten. --- .../Planner/PlaceFormModal.test.tsx | 62 ++++++++++++++++++ .../src/components/Planner/PlaceFormModal.tsx | 65 +++++++++---------- .../screens/trip/sheets/PlPlaceSearch.tsx | 45 +++++++++++-- client/src/utils/placeSource.ts | 39 +++++++++++ server/src/nest/maps/maps.service.ts | 18 ++++- .../tests/unit/nest/maps.autocomplete.test.ts | 13 ++++ shared/src/maps/maps.schema.ts | 20 ++++++ 7 files changed, 223 insertions(+), 39 deletions(-) create mode 100644 client/src/utils/placeSource.ts diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index ff5e1682f..f1e4abb29 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -299,6 +299,43 @@ describe('PlaceFormModal', () => { delete window.__addToast; }); + it('FE-PLANNER-PLACEFORM-021e: an OpenStreetMap suggestion keeps its own coordinates instead of searching for its label', async () => { + // The layer's second line is the name written on the building, not an + // address. Joining the two and searching for it asks a question nobody + // typed, and whatever came back first was taken as the place the user had + // already picked. The row carries coordinates, so the fallback uses those. + const user = userEvent.setup(); + let searched = 0; + server.use( + http.post('/api/maps/autocomplete', () => + HttpResponse.json({ + suggestions: [{ + placeId: 'node:9712313', + mainText: 'Tokio Hauptbahnhof', + secondaryText: '東京駅丸の内駅舎', + source: 'openstreetmap', + lat: 35.6811816, + lng: 139.76598265, + }], + source: 'trek-places', + }), + ), + http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })), + http.post('/api/maps/search', () => { + searched += 1; + return HttpResponse.json({ places: [{ name: 'Etwas ganz anderes', lat: '1', lng: '1' }], source: 'trek-places' }); + }), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Tokio'); + await user.click(await screen.findByText('Tokio Hauptbahnhof')); + + expect(await screen.findByDisplayValue('35.6811816')).toBeInTheDocument(); + expect(screen.getByDisplayValue('139.76598265')).toBeInTheDocument(); + expect(searched).toBe(0); + }); + it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => { const user = userEvent.setup(); server.use( @@ -375,6 +412,31 @@ describe('PlaceFormModal', () => { expect(await screen.findByText('TREK')).toBeInTheDocument(); }); + it('FE-PLANNER-PLACEFORM-022d: the typed-ahead list marks the OpenStreetMap rows as such', async () => { + // The keystroke path asks both indexes at once, so the name the response + // carries for the whole list ('trek-places') is true of the call and wrong + // for half the rows. Marking every suggestion TREK is how a list that did + // contain OpenStreetMap places read as if it never had. + const user = userEvent.setup(); + server.use( + http.post('/api/maps/autocomplete', () => + HttpResponse.json({ + suggestions: [ + { placeId: 'gers:abc', mainText: 'Tokyo Station Beer Stand', secondaryText: 'Chiyoda', source: 'trek-places' }, + { placeId: 'node:9712313', mainText: 'Tokio Hauptbahnhof', secondaryText: '東京駅', source: 'openstreetmap' }, + ], + source: 'trek-places', + }), + ), + ); + + render(); + await user.type(screen.getByPlaceholderText('Search places...'), 'Tokyo Station'); + + expect(await screen.findByText('TREK')).toBeInTheDocument(); + expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument(); + }); + it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => { // The whole point of putting it on the row: with both indexes answering, // a label above the list could only name one of them. The index marks its diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 8106b8bea..f6597cd1b 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -18,6 +18,7 @@ import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' +import { sourceLabelFor } from '../../utils/placeSource' import { useLocationBias } from '../../hooks/useLocationBias' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -56,36 +57,19 @@ interface PlaceFormModalProps { /** - * Which index a search result came from, as a short mark beside it. + * One row of the typed-ahead list, as the server sends it. * - * A place list can be two indexes interleaved, so the source belongs on the row - * rather than above the list: with the TREK index and OpenStreetMap answering - * together, "one of these came from somewhere" is not an answer anyone can use. - * - * The three names are proper nouns, so they are not translated, and that is also - * why there is no fourth: a source without a name people already know would need - * a string in 23 languages to say less than nothing. + * `source`, `lat` and `lng` are optional because not every index fills them: + * Google answers with neither, and the mark falls back to the name the whole + * list carries. */ -const SOURCE_LABELS: Record = { - 'trek-places': 'TREK', - openstreetmap: 'OpenStreetMap', - nominatim: 'OpenStreetMap', - google: 'Google', -} - -/** - * The label for one row. - * - * A place carries its own source when the index that produced it says so, which - * is what makes an interleaved list readable. Everything else falls back to what - * answered the call: Google never marks its places, and a merged list marks only - * the index side, so an unmarked row in one is OpenStreetMap by elimination. - */ -function sourceLabelFor(place: unknown, listSource: string): string | null { - const own = (place as { source?: unknown } | null)?.source - if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null - if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap - return SOURCE_LABELS[listSource] ?? null +type Suggestion = { + placeId: string + mainText: string + secondaryText: string + source?: string + lat?: number + lng?: number } /** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */ @@ -161,10 +145,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const [pendingFiles, setPendingFiles] = useState([]) const fileRef = useRef(null) const searchInputRef = useRef(null) - const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([]) - // Which index answered the last keystroke. One call is served by one source, - // so the whole list carries it; the search below is per place, because that - // list can be two indexes interleaved. + const [acSuggestions, setAcSuggestions] = useState([]) + // Which index answered the last keystroke, for the rows that do not say so + // themselves. Google and the OpenStreetMap fallback each answer from one + // place; the index path answers from two at once and marks every row. const [acSource, setAcSource] = useState('') const [acHighlight, setAcHighlight] = useState(-1) const acDebounceRef = useRef | null>(null) @@ -420,7 +404,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setMapsSearch('') } - const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => { + const handleSelectSuggestion = async (suggestion: Suggestion) => { // Read before the list is cleared: this is the rank the user saw. const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId) const acCount = acSuggestions.length @@ -448,6 +432,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } catch (err) { console.error('Failed to fetch place details:', err) } + if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { + // The layer's rows carry no address; their second line is the name + // written on the building. Searching for "Tokio Hauptbahnhof, 東京駅" + // is not a question anybody asked, and its first answer would be + // whatever the index made of it — a different place, chosen silently. + // The suggestion already knows where it is, so use that. + place = { + name: suggestion.mainText, + address: '', + lat: suggestion.lat, + lng: suggestion.lng, + osm_id: suggestion.placeId, + source: 'openstreetmap', + } + } if (!place) { const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') const search = await mapsApi.search(query, language, locationBiasPoint) diff --git a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx index e6ead9394..ec1b26f04 100644 --- a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx +++ b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Search } from 'lucide-react' import { mapsApi } from '../../../../api/client' +import { sourceLabelFor } from '../../../../utils/placeSource' import { recordPlacePick } from '../../../../api/placeShadow' import { PlacesSession } from '../../../../utils/placesSession' import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers' @@ -26,10 +27,24 @@ interface Suggestion { placeId: string mainText: string secondaryText: string + /** Which of the two indexes this row came from, when the list is both. */ + source?: string + lat?: number + lng?: number } type MapsPlace = Record +/** The same mark the desktop form shows, in the sheet's own tokens. */ +function SourceMark({ label }: { label: string | null }) { + if (!label) return null + return ( + + {label} + + ) +} + interface PlPlaceSearchProps { planner: TripPlanner /** Search bias derived from the trip's existing places (trip centre). */ @@ -76,6 +91,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi // has to read the values that belonged to that list. Mirrors PlaceFormModal. const searchMetaRef = useRef<{ query: string; source: string } | null>(null) const acMetaRef = useRef<{ query: string; source: string } | null>(null) + // The name the whole list carries, for rows that do not name their own index. + const [acSource, setAcSource] = useState('') const setResolving = useCallback( (v: boolean) => { @@ -93,6 +110,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi try { const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current()) acMetaRef.current = { query: input, source: result.source || 'unknown' } + setAcSource(result.source || '') setSuggestions(result.suggestions || []) } catch (err: unknown) { // Superseded request — axios rejects an aborted call with CanceledError. @@ -211,6 +229,20 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi } catch { // fall through to text search } + if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) { + // The layer's second line is the local name, not an address, so joining + // the two makes a query nobody typed — and its first answer would be + // silently taken as the place the user picked. The row already knows + // where it is. + place = { + name: suggestion.mainText, + address: '', + lat: suggestion.lat, + lng: suggestion.lng, + osm_id: suggestion.placeId, + source: 'openstreetmap', + } + } if (!place) { const fullQuery = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ') const search = await mapsApi.search(fullQuery, language, pointFromBox(locationBias)) @@ -269,10 +301,15 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi onClick={() => handleSelectSuggestion(s)} className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0" > -
{s.mainText}
- {s.secondaryText && ( -
{s.secondaryText}
- )} +
+
+
{s.mainText}
+ {s.secondaryText && ( +
{s.secondaryText}
+ )} +
+ +
))}
diff --git a/client/src/utils/placeSource.ts b/client/src/utils/placeSource.ts new file mode 100644 index 000000000..0d651cf5e --- /dev/null +++ b/client/src/utils/placeSource.ts @@ -0,0 +1,39 @@ +/** + * Which index a place came from, as a short mark beside it. + * + * A result list can be two indexes interleaved, so the source belongs on the + * row rather than above the list: with the TREK index and OpenStreetMap + * answering together, "one of these came from somewhere" is not an answer + * anyone can use. + * + * Here rather than beside one screen because both the desktop form and the + * mobile sheet show the same lists, and a mark that says TREK on one and + * nothing on the other is worse than either alone. + */ + +/** + * The three names are proper nouns, so they are not translated, and that is + * also why there is no fourth: a source without a name people already know + * would need a string in 23 languages to say less than nothing. + */ +export const SOURCE_LABELS: Record = { + 'trek-places': 'TREK', + openstreetmap: 'OpenStreetMap', + nominatim: 'OpenStreetMap', + google: 'Google', +} + +/** + * The label for one row. + * + * A place carries its own source when the index that produced it says so, which + * is what makes an interleaved list readable. Everything else falls back to what + * answered the call: Google never marks its places, and a merged list marks only + * the index side, so an unmarked row in one is OpenStreetMap by elimination. + */ +export function sourceLabelFor(place: unknown, listSource: string): string | null { + const own = (place as { source?: unknown } | null)?.source + if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null + if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap + return SOURCE_LABELS[listSource] ?? null +} diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index a5eaa085a..016f6c40c 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -2021,7 +2021,7 @@ export class MapsService { lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, sessionToken?: string, - ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + ): Promise { const { key: apiKey, source: keySource } = this.resolveMapsKey(userId); // This is the path that mattered most. Nominatim's usage policy names @@ -2071,11 +2071,25 @@ export class MapsService { // place is called on the spot, which is more use under a // translated label than an empty line. secondaryText: p.local_name && p.local_name !== p.name ? p.local_name : '', + // Per row, because this list is two indexes interleaved. + // The name above the list says `trek-places`, which is true + // of the call and false of half the rows in it — the layer + // is OpenStreetMap, and a reader deciding whether to trust + // a suggestion is asking exactly that. + source: 'openstreetmap', + // Both indexes hand these over with the row. Carried rather + // than dropped so picking a suggestion has something to fall + // back on when the details lookup cannot answer. + lat: p.lat, + lng: p.lng, } : { placeId: `gers:${p.gers}`, mainText: p.name, secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '), + source: 'trek-places', + lat: p.lat, + lng: p.lng, }, ), source: 'trek-places', @@ -2143,7 +2157,7 @@ export class MapsService { private async autocompleteNominatim( input: string, lang?: string, - ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + ): Promise { try { const places = await this.searchNominatim(input, lang); const suggestions = places diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index 5f4fcd71c..b0705216d 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -196,6 +196,19 @@ describe('MapsService.autocompletePlaces', () => { expect(second.suggestions[0].secondaryText).toBe(''); }); + it('MAPS-AUTO-010: each row says which index it came from, because the list is two', async () => { + mockSearch.mockResolvedValue([hit(), osmHit()]); + + const { suggestions, source } = await make().autocompletePlaces(1, INPUT); + + // The name above the list describes the call, and the call asked both. Only + // the row can say which of the two answered it — without that the reader is + // told a place from OpenStreetMap came out of the TREK index, which is the + // one thing the mark beside a suggestion exists to answer. + expect(source).toBe('trek-places'); + expect(suggestions.map(s => s.source)).toEqual(['trek-places', 'openstreetmap']); + }); + it('MAPS-AUTO-007: the keystroke never leaves for the index while the admin has it off', async () => { mockSearch.mockResolvedValue([hit()]); diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index f79b8ff9a..729343eac 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -68,6 +68,26 @@ export const mapsAutocompleteSuggestionSchema = z.object({ placeId: z.string(), mainText: z.string(), secondaryText: z.string(), + /** + * Which index this one row came from, when the answer is more than one index + * interleaved. The list-level `source` cannot say it: the keystroke path asks + * the TREK index and the OpenStreetMap layer together, so a single name above + * the list marks half the rows wrong. + * + * Optional because Google and the Nominatim fallback each answer from one + * place, and a row that names no source falls back to the list's. + */ + source: z.string().optional(), + /** + * Where the place is, when the index that answered already said so. + * + * The OpenStreetMap layer returns coordinates with every row, and throwing + * them away here cost the client a second round trip on every pick — and, + * when that round trip failed, a text search built out of a name and its + * local spelling, which is not a query anybody would type. + */ + lat: z.number().optional(), + lng: z.number().optional(), }); export const mapsAutocompleteResultSchema = z.object({ suggestions: z.array(mapsAutocompleteSuggestionSchema), From 29fc586930ae53ef1803bc91b59e1c1011db2d64 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 18:07:06 +0200 Subject: [PATCH 12/29] test(maps): pin the coordinates a suggestion carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The half of the change that saves the pick from a text search for its own label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there; the client test writes the coordinates into its own mock and then asserts them. Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎" and taking whatever comes back first. So the server test now asserts the coordinates on the wire for both indexes, and the mobile sheet — which carries a copy of the same fallback and had no test for it at all — gets one for the fallback and one for the source mark. --- .../unit/mobile/trip/PlPlaceSearch.test.tsx | 41 +++++++++++++++++++ .../tests/unit/nest/maps.autocomplete.test.ts | 12 ++++++ 2 files changed, 53 insertions(+) diff --git a/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx b/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx index bf755e99d..29823408f 100644 --- a/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx +++ b/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx @@ -132,6 +132,47 @@ describe('PlPlaceSearch', () => { expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Louvre Museum', lat: '48.8606' })) }) + it('FE-MOB-PLSRCH-005b: an OpenStreetMap row keeps its own coordinates instead of searching for its label', async () => { + // The layer's second line is the name written on the building, not an + // address, so joining the two asks a question nobody typed — and whatever + // came back first was taken as the place the user had already picked. + server.use( + recordAutocomplete([{ + placeId: 'node:9712313', + mainText: 'Tokio Hauptbahnhof', + secondaryText: '東京駅丸の内駅舎', + source: 'openstreetmap', + lat: 35.6811816, + lng: 139.76598265, + }]), + http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })), + recordSearch(), + ) + const { input, onPick } = setup() + fireEvent.change(input, { target: { value: 'Tok' } }) + fireEvent.click(await screen.findByText('Tokio Hauptbahnhof')) + + await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2)) + expect(searchBodies).toHaveLength(0) + expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ + name: 'Tokio Hauptbahnhof', lat: '35.6811816', lng: '139.76598265', + })) + }) + + it('FE-MOB-PLSRCH-005c: a suggestion says which index answered', async () => { + // Both indexes answer the keystroke path at once, so the name the response + // carries for the whole list is true of the call and wrong for half its rows. + server.use(recordAutocomplete([ + { ...SUGGESTION, source: 'trek-places' }, + { placeId: 'node:1', mainText: 'Louvre Palace', secondaryText: 'Palais du Louvre', source: 'openstreetmap' }, + ])) + const { input } = setup() + fireEvent.change(input, { target: { value: 'Lou' } }) + + expect(await screen.findByText('TREK')).toBeInTheDocument() + expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument() + }) + it('FE-MOB-PLSRCH-006: also falls back when the details hop answers without coordinates', async () => { server.use( recordAutocomplete(), diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index b0705216d..a51cc808d 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -209,6 +209,18 @@ describe('MapsService.autocompletePlaces', () => { expect(suggestions.map(s => s.source)).toEqual(['trek-places', 'openstreetmap']); }); + it('MAPS-AUTO-011: a suggestion carries the coordinates the index already gave, so the pick needs no second hop', async () => { + mockSearch.mockResolvedValue([hit(), osmHit()]); + + const { suggestions } = await make().autocompletePlaces(1, INPUT); + + // Without these the client, when the details lookup cannot answer, searches + // for the label instead — and for a layer row that label is a name plus its + // local spelling, which is not a query anybody typed. + expect(suggestions[0]).toMatchObject({ lat: 54.0879, lng: 12.1408 }); + expect(suggestions[1]).toMatchObject({ lat: 35.6811816, lng: 139.76598265 }); + }); + it('MAPS-AUTO-007: the keystroke never leaves for the index while the admin has it off', async () => { mockSearch.mockResolvedValue([hit()]); From 77162aa8d5c07b32a9150f1c9be6bb81eb2828aa Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 18:19:31 +0200 Subject: [PATCH 13/29] feat(roadtrip): avoid tolls, motorways and ferries, through a second engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every leg: their car profile is built without excludable classes, and their own Lua says why ("no memory for classes on demo server"). So "drive this without the tolls" produced nothing at all on a default install, and the fallback inside the alternatives bar quietly gave up after one refusal per host. FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds it as a second engine asked only the questions the first cannot answer, and puts three switches under the road trip settings that change the road itself rather than offering an alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against 492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km, which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute rather than driving round. Four things the docs would have got wrong, all measured against the live instance: - `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this, and it exists only in the native answer — which is also 5.7x smaller, because the OSRM shape drags every intersection along. So the adapter translates instead of borrowing a familiar envelope. - Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search radius is allowed. TREK is full of such places, so every location carries one. - Ten locations is a hard cap and a road trip day is bundled into one request. A day over it is split into runs overlapping by one point, which is exact rather than approximate: a measured six-stop day came back within two metres and under a second of the same day asked whole. - Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and those times feed the arrival clocks of a day. So a day is answered by one engine or by neither, never by both, and a piece that will not answer loses the whole day rather than half of it — the schedule chains leg times blindly and would otherwise print a complete, plausible, silently wrong timetable. Because the weighting can fail, the honest surface is the answer rather than the request. `use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what the road actually turned out to be is read back per leg, and a day that could not avoid what it was asked to avoid says so on its own header instead of wearing a label the road disproves. The switches disable themselves, with a reason, on an instance pointed at its own OSRM: that operator chose against public hosts, and a second one they never asked for would undo the choice. Two things that would have broken quietly and did not: - The route cache keys on `profile:unit:coords` and is shared with the day planner, the studio and the booking geometry. Without the avoidance in the key, flipping a switch returns the old road out of cache in under a millisecond — and worse, an avoiding answer stored under the plain key hands those other callers a road nobody drives. - `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla echoes the coordinates it was given and never says where it bound them, but the ends of a leg's line are exactly those points: measured against Füssen, its first vertex matches OSRM's snapped location to the digit. The alternatives bar gets the same engine, which finally makes its "no motorway" and "no tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares geometry rather than numbers there, because two engines' distances for the same road sit kilometres apart and the old 200 m tolerance called that a discovery. Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it. --- .../Admin/DefaultUserSettingsTab.tsx | 26 ++ .../components/Map/RouteCalculator.test.ts | 113 +++++ client/src/components/Map/RouteCalculator.ts | 252 +++++++++-- .../src/components/Map/valhallaRoute.test.ts | 367 ++++++++++++++++ client/src/components/Map/valhallaRoute.ts | 411 ++++++++++++++++++ .../Roadtrip/RoadtripLimitsCard.tsx | 94 +++- .../components/Roadtrip/RoadtripSidebar.tsx | 17 +- .../Roadtrip/alternativeOverlays.test.ts | 2 +- .../Roadtrip/alternativeOverlays.ts | 6 +- .../src/components/Roadtrip/roadtripModel.ts | 25 ++ .../components/Roadtrip/useRoadtripRoutes.ts | 35 +- .../src/pages/tripPlanner/useTripPlanner.ts | 3 +- client/src/store/settingsStore.ts | 3 + client/src/types.ts | 28 ++ client/tests/helpers/msw/handlers/external.ts | 15 + server/src/bootstrap.ts | 12 +- server/src/middleware/globalMiddleware.ts | 6 + server/src/nest/common/managed.ts | 1 + server/src/nest/settings/settings.service.ts | 14 +- server/tests/e2e/settings.e2e.test.ts | 17 + .../middleware/globalMiddleware.csp.test.ts | 29 ++ server/tests/unit/nest/managed-keys.test.ts | 1 + shared/src/i18n/ar/roadtrip.ts | 12 +- shared/src/i18n/ar/settings.ts | 2 + shared/src/i18n/br/roadtrip.ts | 12 +- shared/src/i18n/br/settings.ts | 2 + shared/src/i18n/ca/roadtrip.ts | 12 +- shared/src/i18n/ca/settings.ts | 2 + shared/src/i18n/cs/roadtrip.ts | 12 +- shared/src/i18n/cs/settings.ts | 2 + shared/src/i18n/de/roadtrip.ts | 12 +- shared/src/i18n/de/settings.ts | 2 + shared/src/i18n/en/roadtrip.ts | 12 +- shared/src/i18n/en/settings.ts | 2 + shared/src/i18n/es/roadtrip.ts | 12 +- shared/src/i18n/es/settings.ts | 2 + shared/src/i18n/fr/roadtrip.ts | 12 +- shared/src/i18n/fr/settings.ts | 2 + shared/src/i18n/gr/roadtrip.ts | 12 +- shared/src/i18n/gr/settings.ts | 2 + shared/src/i18n/hu/roadtrip.ts | 12 +- shared/src/i18n/hu/settings.ts | 2 + shared/src/i18n/id/roadtrip.ts | 12 +- shared/src/i18n/id/settings.ts | 2 + shared/src/i18n/it/roadtrip.ts | 12 +- shared/src/i18n/it/settings.ts | 2 + shared/src/i18n/ja/roadtrip.ts | 12 +- shared/src/i18n/ja/settings.ts | 2 + shared/src/i18n/ko/roadtrip.ts | 12 +- shared/src/i18n/ko/settings.ts | 2 + shared/src/i18n/nl/roadtrip.ts | 12 +- shared/src/i18n/nl/settings.ts | 2 + shared/src/i18n/pl/roadtrip.ts | 12 +- shared/src/i18n/pl/settings.ts | 2 + shared/src/i18n/ru/roadtrip.ts | 12 +- shared/src/i18n/ru/settings.ts | 2 + shared/src/i18n/sv/roadtrip.ts | 12 +- shared/src/i18n/sv/settings.ts | 2 + shared/src/i18n/tr/roadtrip.ts | 12 +- shared/src/i18n/tr/settings.ts | 2 + shared/src/i18n/uk/roadtrip.ts | 12 +- shared/src/i18n/uk/settings.ts | 2 + shared/src/i18n/vi/roadtrip.ts | 12 +- shared/src/i18n/vi/settings.ts | 2 + shared/src/i18n/zh-TW/roadtrip.ts | 12 +- shared/src/i18n/zh-TW/settings.ts | 2 + shared/src/i18n/zh/roadtrip.ts | 12 +- shared/src/i18n/zh/settings.ts | 2 + 68 files changed, 1729 insertions(+), 70 deletions(-) create mode 100644 client/src/components/Map/valhallaRoute.test.ts create mode 100644 client/src/components/Map/valhallaRoute.ts diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index b800c3026..d89fa5964 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -44,6 +44,7 @@ type Defaults = { map_tile_url?: string carto_api_key?: string routing_base_url?: string + valhalla_base_url?: string map_provider?: string mapbox_access_token?: string mapbox_style?: string @@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { const [mapboxToken, setMapboxToken] = useState('') const [cartoKey, setCartoKey] = useState('') const [routingBase, setRoutingBase] = useState('') + const [valhallaBase, setValhallaBase] = useState('') const [mapboxStyle, setMapboxStyle] = useState('') useEffect(() => { @@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { setMapboxToken(data.mapbox_access_token || '') setCartoKey(data.carto_api_key || '') setRoutingBase(data.routing_base_url || '') + setValhallaBase(data.valhalla_base_url || '') setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style)) setLoaded(true) }).catch(() => setLoaded(true)) @@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { if (key === 'mapbox_access_token') setMapboxToken('') if (key === 'carto_api_key') setCartoKey('') if (key === 'routing_base_url') setRoutingBase('') + if (key === 'valhalla_base_url') setValhallaBase('') if (key === 'mapbox_style' || key === 'maplibre_style') { const provider = normalizeProvider(defaults.map_provider) setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider)) @@ -409,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {

{t('settings.routingBaseHint')}

)} + {/* The second engine, next to the first because it is the same kind of decision + and carries the same restart caveat. Only ever asked to leave a road class + out of one leg, which is the one question the OSRM above refuses. */} + {!managed && ( +
+ + ) => setValhallaBase(e.target.value)} + onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })} + placeholder="https://valhalla.example.org" + spellCheck={false} + autoComplete="off" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('settings.valhallaBaseHint')}

+
+ )}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {React.createElement(MapView as any, { diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 058983811..e7348e037 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -797,6 +797,119 @@ describe('calculateAlternatives', () => { expect(asked).toHaveLength(1) }) + // The second engine, which is what made this whole branch produce anything on a + // default install. Every case here overrides the 503 the shared handlers answer with, + // so the OSRM path above stays the one the cases before this measure. + const VALHALLA = 'https://valhalla1.openstreetmap.de/route' + const valhallaAnswer = (summary: Record) => ({ + trip: { + // The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends. + legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }], + summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary }, + }, + }) + + it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => { + server.use( + http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })), + http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false }))) + ) + + const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) + + expect(routes).toHaveLength(2) + expect(routes[1].avoids).toBe('motorway') + expect(routes[1].distance).toBeCloseTo(130000, 0) + expect(routes[1].duration).toBe(4500) + }) + + it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => { + // use_highways: 0 is a weighting, so a leg with no way round the motorway comes back + // on the motorway and says so. Offering that as "No motorway" would put a lie on the + // map, so it is dropped and the leg keeps the single way OSRM found. + // + // Asserted on the result rather than on which excludes OSRM was asked: by this point + // `excludeUnsupported` has already recorded the public host from the case above, and + // that Set is module state for the lifetime of the file. + server.use( + http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { + const exclude = new URL(request.url).searchParams.get('exclude') + if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) + return new HttpResponse(null, { status: 400 }) + }), + http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true }))) + ) + + const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 }) + + expect(routes).toHaveLength(1) + expect(routes[0].avoids).toBeUndefined() + }) + + it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => { + server.use( + http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { + const exclude = new URL(request.url).searchParams.get('exclude') + if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) + return new HttpResponse(null, { status: 400 }) + }), + http.post(VALHALLA, async ({ request }) => { + const body = await request.json() as { costing_options: { auto: Record } } + // Only the ferry question gets a usable answer, so the first two fall through. + if (!('use_ferry' in body.costing_options.auto)) { + return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true })) + } + return HttpResponse.json(valhallaAnswer({ has_ferry: false })) + }) + ) + + const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 }) + + expect(routes).toHaveLength(2) + expect(routes[1].avoids).toBe('ferry') + }) + + it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => { + // The reason the geometry decides rather than the numbers. Valhalla prices roads + // differently, so on a long leg the SAME road comes back kilometres apart in both + // length and time — here 13 km and 5 minutes, which is the measured spread between + // the two engines and comfortably past the 200 m the numbers used to allow. Judged + // on those it reads as a discovery and puts a second blue line on top of the first. + server.use( + http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })), + http.post(VALHALLA, () => HttpResponse.json({ + trip: { + // twoRoutes.routes[0]'s own line, re-encoded: the identical road. + legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }], + summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false }, + }, + })) + ) + + const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) + + expect(routes).toHaveLength(1) + }) + + it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => { + let valhallaCalls = 0 + useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } })) + server.use( + http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => { + const exclude = new URL(request.url).searchParams.get('exclude') + if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) + return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] }) + }), + http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) }) + ) + + const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 }) + + expect(valhallaCalls).toBe(0) + expect(routes[1].avoids).toBe('motorway') + useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) + }) + it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => { // OSRM reports these in every answer and TREK threw them away since the first route // was drawn. Without them a place set back from the road looks like it is on the diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index ce243bf40..10bddc871 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay import { haversineKm } from '../../utils/geo' import { formatDistance } from '../../utils/units' import { countRoute } from './routeUsageCounter' +import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute' import type { RouteUsageSurface } from '@trek/shared' // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the @@ -392,23 +393,55 @@ export async function calculateSegments( */ export async function calculateRouteWithLegs( waypoints: Waypoint[], - { signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {} + { signal, profile = 'driving', tripId, dayId, avoid = [] }: { + signal?: AbortSignal + profile?: RouteProfileKey + tripId?: number | string | null + dayId?: number | null + /** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */ + avoid?: readonly AvoidClass[] + } = {} ): Promise { if (!waypoints || waypoints.length < 2) { return { coordinates: [], distance: 0, duration: 0, legs: [] } } + const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable() + ? [...avoid].sort() + : [] + const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') // The cached result carries formatted leg distances, so the active distance unit is // part of the key — otherwise switching km↔mi would return stale text (#1300). // A plugin route is trip-/day-specific (it may return different charging stops for // the same coordinates on a different day), so its key includes tripId/dayId; // the built-in OSRM profiles are context-free and leave those out. + // + // The avoidance belongs in the key for two reasons pointing opposite ways. Without it + // turning the switch on returns the tolled route out of cache in under a millisecond, + // so nothing on screen changes and it reads as a broken toggle. And the leak the other + // way is worse: this Map is shared with the ordinary day planner, the studio and the + // booking geometry, none of which ask for avoidance, so an avoid-routed answer stored + // under the plain key would hand them a road nobody drives and a second engine's + // times. `avoiding` is empty whenever the request goes to OSRM after all, so the key + // also separates the two engines. const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : '' - const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}` + const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : '' + const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}` const cached = routeCache.get(cacheKey) if (cached) return cached + if (avoiding.length) { + const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal) + // Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a + // day drawn rather than blank, and `avoidance` stays absent on that result so the + // rail can say the avoidance did not happen instead of implying it did. + if (routed) { + cacheRoute(cacheKey, routed) + return routed + } + } + // Plugin profile (`plugin:/`): the server invokes that routeProvider // and normalizes its answer; null means the provider failed or refused, and the // throw makes callers fall back to straight lines exactly like an OSRM outage. @@ -486,12 +519,73 @@ export async function calculateRouteWithLegs( const snapped = readSnapped(data, waypoints) const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) } - routeCache.set(cacheKey, result) + cacheRoute(cacheKey, result) + return result +} + +/** Store a route and drop the oldest once the cache is over its cap. */ +function cacheRoute(key: string, route: RouteWithLegs): void { + routeCache.set(key, route) if (routeCache.size > ROUTE_CACHE_MAX) { const oldest = routeCache.keys().next().value if (oldest !== undefined) routeCache.delete(oldest) } - return result +} + +/** + * A whole chain from the second engine, weighted away from some road classes. + * + * Shaped exactly like the OSRM answer above so the callers never branch on which engine + * spoke — same leg list, same formatted text, same snap positions. What it adds is + * `avoidance`: what was asked for against what the road actually turned out to be, which + * is the only honest way to label a weighting that can fail. + * + * Null rather than a throw when it cannot answer, so the caller falls through to OSRM + * and the day stays drawn. + */ +async function routeAvoidingWithLegs( + waypoints: Waypoint[], + avoid: AvoidClass[], + signal?: AbortSignal, +): Promise { + const sample = { + profile: 'driving' as const, + surface: 'legs' as const, + selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(), + waypoints: waypoints.length, + km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0), + } + const run = await valhallaRun(waypoints, 'driving', avoid, signal) + // Counted by hand because the adapter speaks to a different engine with a different + // verb, and the counter exists to answer "how much routing does this instance do" — + // an engine it cannot see is exactly the traffic that would go missing from it. + if (!signal?.aborted) countRoute({ ...sample, failed: !run }) + if (!run) return null + + const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => { + const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] + const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] + return { + mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2], + from, + to, + distance: leg.distance, + duration: leg.duration, + walkingText: formatDuration(leg.distance / (5000 / 3600)), + drivingText: formatDuration(leg.duration), + distanceText: formatRouteDistance(leg.distance), + durationText: formatDuration(leg.duration), + } + }) + + return { + coordinates: run.total.coordinates, + distance: run.total.distance, + duration: run.total.duration, + legs, + snapped: run.snapped, + avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) }, + } } /** @@ -554,7 +648,7 @@ export interface RouteAlternative { /** Where this route differs most from the first one — the point that would pin it. */ divergence: { lat: number; lng: number } | null /** Set when this way exists because a road class was left out of it. */ - avoids?: 'motorway' | 'toll' + avoids?: AvoidClass } /** @@ -568,11 +662,39 @@ export interface RouteAlternative { * * Only for driving: the foot and bike profiles have no excludable classes, and asking * anyway earns an `InvalidOptions` for nothing. + * + * Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM + * does carry a ferry class, but on a leg where a ferry is the only crossing it answers + * NoRoute rather than the long way round, which is not an alternative anybody can use. + * Valhalla weights it instead and drives around, which is the whole point. */ -const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const +const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const -/** Two lines are the same road if their length and their duration agree closely enough. */ +/** + * How far two lines may stray from each other and still be the same road, in kilometres. + * + * Parallel carriageways, a different slip road onto the same motorway and the noise + * between two engines' idea of where a road is all sit well under this. A way somebody + * would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to + * Copenhagen, which is a genuinely different road, peaks 2.3 km off the default. + */ +const SAME_ROAD_KM = 0.5 + +/** + * Two lines are the same road. + * + * Length and duration used to decide this, and stopped being enough when a second engine + * started answering. Valhalla prices roads differently — measured over ten European legs + * its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same + * road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery. + * Two identical blue lines on the map is exactly what that looks like. + * + * The geometry does not care which engine drew it, so it decides whenever there is one. + * The numbers stay as the fallback for a line with no shape to compare. + */ function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean { + const stray = furthestFrom(b.coordinates, a.coordinates) + if (stray) return stray.km < SAME_ROAD_KM return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60 } @@ -603,22 +725,76 @@ const excludeUnsupported = new Set() * particular question had no answer. */ async function routeExcluding( - coords: string, + from: Waypoint, + to: Waypoint, profile: 'driving' | 'walking' | 'cycling', - exclude: string, + exclude: AvoidClass, + signal?: AbortSignal, +): Promise { + // Valhalla first, because on a default install it is the only one of the two that + // can answer at all. It weights the class away instead of banning it, so it also + // answers on a leg where the class cannot be avoided — and says so, which is why the + // result is thrown away below unless the road really came back without it. + const weighted = await valhallaExcluding(from, to, profile, exclude, signal) + if (weighted) return weighted + + // OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers + // NoRoute rather than driving round, so there is nothing to offer and no point paying + // a request to be told so. + if (exclude === 'ferry') return null + return osrmExcluding(from, to, profile, exclude, signal) +} + +/** + * The same leg from Valhalla, weighted away from one class, or null. + * + * The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so + * between two points with no untolled connection the answer is still a tolled road. + * Offering that as "No tolls" would put a lie on the map, so it is dropped and the + * OSRM attempt gets its turn. + */ +async function valhallaExcluding( + from: Waypoint, + to: Waypoint, + profile: 'driving' | 'walking' | 'cycling', + exclude: AvoidClass, + signal?: AbortSignal, +): Promise { + if (!valhallaAvailable()) return null + const sample = { + profile, + surface: 'alternatives' as const, + selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(), + waypoints: 2, + km: haversineKm(from, to), + } + const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal) + // Counted here rather than in the adapter, so the adapter stays a format translator + // and every routing request an instance makes still lands in the one counter. + if (!signal?.aborted) countRoute({ ...sample, failed: !leg }) + if (!leg || !legAvoids(leg, exclude)) return null + return { + coordinates: leg.coordinates, + distance: leg.distance, + duration: leg.duration, + divergence: null, + } +} + +/** The same leg from OSRM with the class excluded outright, or null. */ +async function osrmExcluding( + from: Waypoint, + to: Waypoint, + profile: 'driving' | 'walking' | 'cycling', + exclude: AvoidClass, signal?: AbortSignal, ): Promise { const base = routeBaseFor(profile) if (excludeUnsupported.has(base)) return null try { + const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson` - // This one is handed its points already encoded, so they are read back out for the - // counter rather than threaded through a second parameter nobody else needs. - const points = coords.split(';').map(pair => { - const [lng, lat] = pair.split(',').map(Number) - return { lat, lng } - }) - const response = await routedFetch(url, signal, 'alternatives', profile, points) + const response = await routedFetch(url, signal, 'alternatives', profile, [from, to]) if (!response.ok) { // 400 is the router saying the parameter itself is not available here, which is // true of every leg from now on. Anything else is about this request alone. @@ -675,19 +851,18 @@ export async function calculateAlternatives( // Nothing but the router's own preference came back, which is what it answers on most // long legs. Rather than reporting "only one sensible way", ask a different question: - // the same drive without the motorway, and failing that without the tolls. Tried one at - // a time and stopped as soon as one lands, so a leg that does have a second road costs - // one extra request rather than three. + // the same drive without the motorway, then without the tolls, then without a ferry. + // Tried one at a time and stopped as soon as one lands, so a leg that does have a + // second road costs one extra request rather than three. // - // Worth knowing before reading further: on the two public hosts TREK ships with, this - // never produces anything. Their car profile is built without excludable classes and - // the parameter comes back as HTTP 400, which is why long legs keep reporting a single - // way to drive them. It works against a self-hosted OSRM built through the MLD - // pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once. + // This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP + // 400 on both public hosts, whose car profile is built without excludable classes. It + // now asks Valhalla first, where avoidance is a runtime weighting, and only falls back + // to OSRM's `exclude` for an instance running its own MLD-built router. if (routes.length < 2 && profile === 'driving') { for (const exclude of EXCLUDABLE_CLASSES) { if (signal?.aborted) break - const detour = await routeExcluding(coords, profile, exclude, signal) + const detour = await routeExcluding(from, to, profile, exclude, signal) if (detour && !routes.some(r => sameRoad(r, detour))) { detour.avoids = exclude routes.push(detour) @@ -705,22 +880,43 @@ export async function calculateAlternatives( /** The point of `line` that lies furthest from `reference`, in plain degrees. */ function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null { + const furthest = furthestFrom(line, reference) + return furthest ? { lat: furthest.lat, lng: furthest.lng } : null +} + +/** + * The same point, plus how far off it actually is. + * + * The search itself stays in squared degrees with a cosine on the longitude: it only has + * to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys + * nothing for it. The winner is converted once at the end, because `sameRoad` needs a + * real distance rather than a rank, and half a degree is a different number of metres in + * Andalusia than in Lapland. + */ +function furthestFrom( + line: [number, number][], + reference: [number, number][], +): { lat: number; lng: number; km: number } | null { if (!line.length || !reference.length) return null // Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a // full cross product of two thousand-point lines is not worth the milliseconds. const step = Math.max(1, Math.floor(reference.length / 200)) let best: { lat: number; lng: number } | null = null + let bestNeighbour: [number, number] | null = null let bestDist = -1 for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) { const [lat, lng] = line[i] let nearest = Infinity + let nearestPoint: [number, number] | null = null for (let j = 0; j < reference.length; j += step) { const dLat = lat - reference[j][0] const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180) const d = dLat * dLat + dLng * dLng - if (d < nearest) nearest = d + if (d < nearest) { nearest = d; nearestPoint = reference[j] } } - if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } } + if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint } } - return best + if (!best) return null + const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0 + return { ...best, km } } diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts new file mode 100644 index 000000000..14526e08e --- /dev/null +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -0,0 +1,367 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '../../../tests/helpers/msw/server' +import { useSettingsStore } from '../../store/settingsStore' +import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute' + +const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route' + +/** + * A real polyline6, encoded from three known points and verified by decoding it back. + * Precision matters here more than anywhere else in the file: read as polyline5 the + * first vertex would be 535.511, which is not a latitude, and a wrong guess draws a + * line into the sea rather than failing. + */ +const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE' +const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]] + +const answer = (summary: Record = {}) => ({ + trip: { + legs: [{ shape: SHAPE }], + summary: { + has_time_restrictions: false, + has_toll: false, + has_highway: true, + has_ferry: false, + // Asked in kilometres, so this is 295.7 km and 191 minutes — the real + // Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds. + length: 295.7, + time: 11460, + ...summary, + }, + }, +}) + +const setSettings = (patch: Record) => + useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } })) + +afterEach(() => { + setSettings({ routing_base_url: '', valhalla_base_url: '' }) +}) + +const HAMBURG = { lat: 53.5511, lng: 9.9937 } +const BERLIN = { lat: 52.52, lng: 13.405 } + +describe('valhallaBase', () => { + it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => { + expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de') + expect(valhallaAvailable()).toBe(true) + }) + + it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => { + setSettings({ valhalla_base_url: 'https://valhalla.example.org//' }) + expect(valhallaBase()).toBe('https://valhalla.example.org') + }) + + it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => { + // An operator who filled in their own router chose against the public hosts, usually + // because the instance is not supposed to reach third parties at all. Adding one they + // never asked for would undo that, and their MLD-built OSRM answers exclude itself. + setSettings({ routing_base_url: 'https://osrm.example.org' }) + expect(valhallaBase()).toBeNull() + expect(valhallaAvailable()).toBe(false) + }) + + it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => { + setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' }) + expect(valhallaBase()).toBe('https://v.example.org') + }) +}) + +describe('runFrom', () => { + const total = (data: unknown) => runFrom(data)?.total ?? null + + it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => { + const leg = total(answer()) + expect(leg).not.toBeNull() + expect(leg!.coordinates).toEqual(SHAPE_POINTS) + expect(leg!.distance).toBeCloseTo(295700, 0) + expect(leg!.duration).toBe(11460) + }) + + it('VALHALLA-PARSE-002: carries the flags through as booleans', () => { + const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false })) + expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false }) + }) + + it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => { + // The public instance omits a flag it has nothing to say about, and the caller turns + // this into a label. Undefined has to become false, not a truthy object. + const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } }) + expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false }) + }) + + it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => { + // What a request with a via really answers: one summary per pair of stops. The + // schedule is built from those, so flattening them into a total would lose the + // arrival time at every stop but the last. + const chain = { + trip: { + legs: [ + { shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } }, + { shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } }, + ], + summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false }, + }, + } + const run = runFrom(chain)! + expect(run.legs.map(l => l.distance)).toEqual([100000, 50000]) + expect(run.legs.map(l => l.hasToll)).toEqual([false, true]) + expect(run.total.distance).toBe(150000) + expect(run.total.duration).toBe(5400) + // One tolled leg makes the drive tolled. `every` here would call a day with a + // single toll booth in it toll-free. + expect(run.total.hasToll).toBe(true) + expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2) + }) + + it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => { + // There is no honest way to divide one total between them, and a guess would reach + // the user as a wrong arrival time rather than as a missing one. + const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } } + expect(runFrom(two)).toBeNull() + }) + + it('VALHALLA-PARSE-005: an error body is not a leg', () => { + // What a 400 carries: no trip at all. Parsing it as one would produce a route of + // NaN kilometres rather than the null every caller reads as "no second way". + expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull() + expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull() + expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull() + expect(total(null)).toBeNull() + }) + + it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => { + expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull() + }) +}) + +describe('legAvoids', () => { + const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({ + coordinates: SHAPE_POINTS, distance: 1, duration: 1, + hasToll: false, hasHighway: false, hasFerry: false, ...flags, + }) + + it('VALHALLA-AVOID-001: reads the answer, not the request', () => { + // use_tolls: 0 is a weighting. Between two points with no untolled connection the + // answer is still a tolled road, honestly flagged, and labelling that "No tolls" + // would put a lie on the map. + expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true) + expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false) + }) + + it('VALHALLA-AVOID-002: each class reads its own flag', () => { + expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false) + expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true) + expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false) + expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true) + }) +}) + +describe('valhallaRouteAvoiding', () => { + it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => { + let body: Record | null = null + server.use( + http.post(FOSSGIS_VALHALLA, async ({ request }) => { + body = await request.json() as Record + return HttpResponse.json(answer()) + }) + ) + const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll') + expect(leg!.distance).toBeCloseTo(295700, 0) + expect(body).toMatchObject({ + costing: 'auto', + costing_options: { auto: { use_tolls: 0 } }, + directions_options: { units: 'kilometers' }, + }) + // The radius is what makes a place in a pedestrian zone routable at all: without it + // Vienna's Stephansdom answers error 442 and the whole leg is lost. + expect(body!.locations).toEqual([ + { lat: 53.5511, lon: 9.9937, radius: 50 }, + { lat: 52.52, lon: 13.405, radius: 50 }, + ]) + }) + + it('VALHALLA-REQ-002: each class maps to its own costing option', async () => { + const seen: Record[] = [] + server.use( + http.post(FOSSGIS_VALHALLA, async ({ request }) => { + seen.push(await request.json() as Record) + return HttpResponse.json(answer()) + }) + ) + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway') + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry') + expect(seen.map(b => b.costing_options)).toEqual([ + { auto: { use_highways: 0 } }, + { auto: { use_ferry: 0 } }, + ]) + }) + + it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => { + const seen: string[] = [] + server.use( + http.post(FOSSGIS_VALHALLA, async ({ request }) => { + const body = await request.json() as { costing: string } + seen.push(body.costing) + return HttpResponse.json(answer()) + }) + ) + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry') + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry') + expect(seen).toEqual(['pedestrian', 'bicycle']) + }) + + it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => { + // Every failure means the same thing to the caller — there is no second way to + // show — so a 400 for an unroutable point and a 429 for the rate limit both end + // here rather than as an error over a map that is drawing fine. + server.use( + http.post(FOSSGIS_VALHALLA, () => + HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 }) + ) + ) + await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull() + }) + + it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => { + let calls = 0 + setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' }) + server.use( + http.post('https://not-valhalla.example.org/route', () => { + calls++ + return new HttpResponse(null, { status: 404 }) + }) + ) + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll') + await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway') + // A 404 is about the URL rather than about this leg, so remembering it saves every + // later leg a request against a host that will never answer. + expect(calls).toBe(1) + }) + + it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => { + let calls = 0 + setSettings({ routing_base_url: 'https://osrm.example.org' }) + server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) })) + await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull() + expect(calls).toBe(0) + }) +}) + +describe('valhallaRun over a whole day', () => { + /** A chain answer with one summary per leg, which is what a real request returns. */ + const chain = (legCount: number) => ({ + trip: { + legs: Array.from({ length: legCount }, () => ({ + shape: SHAPE, + summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false }, + })), + summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false }, + }, + }) + + const stops = (n: number) => + Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 })) + + it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => { + let calls = 0 + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + calls++ + const body = await request.json() as { locations: unknown[] } + return HttpResponse.json(chain(body.locations.length - 1)) + })) + + const run = await valhallaRun(stops(6), 'driving', []) + + expect(calls).toBe(1) + expect(run!.legs).toHaveLength(5) + expect(run!.total.distance).toBe(50000) + }) + + it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => { + // Ten locations is hard: the eleventh answers error 150. Split runs overlap by one + // point, which is exact rather than approximate — measured over a six-stop day, + // [0..3] plus [3..5] came back within two metres of the same day asked whole. + const sizes: number[] = [] + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + const body = await request.json() as { locations: unknown[] } + sizes.push(body.locations.length) + return HttpResponse.json(chain(body.locations.length - 1)) + })) + + const run = await valhallaRun(stops(14), 'driving', ['toll']) + + // Fourteen stops: the first run takes ten, the second starts on the tenth again and + // takes the remaining five. Nine pairs plus four. + expect(sizes).toEqual([10, 5]) + // Thirteen pairs across two requests, none lost and none counted twice. + expect(run!.legs).toHaveLength(13) + expect(run!.total.distance).toBe(130000) + }) + + it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => { + const sizes: number[] = [] + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + const body = await request.json() as { locations: unknown[] } + sizes.push(body.locations.length) + return HttpResponse.json(chain(body.locations.length - 1)) + })) + + await valhallaRun(stops(28), 'driving', []) + + expect(Math.max(...sizes)).toBeLessThanOrEqual(10) + expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27) + }) + + it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => { + // Half a day of legs is worse than none. The schedule chains leg times blindly and + // cannot tell that some are missing, so it would print a complete, plausible, + // silently wrong timetable instead of leaving a visible gap. + // + // The SECOND CHUNK fails, not the second request: one attempt is retried, so failing + // by call number would just be answered on the retry. + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + const body = await request.json() as { locations: unknown[] } + if (body.locations.length < 10) { + return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 }) + } + return HttpResponse.json(chain(body.locations.length - 1)) + })) + + await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull() + }) + + it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => { + // The caller retries the whole task, so without this a rate limit on the last piece + // of a split day throws away the pieces that already answered and asks for all of + // them again — three times over, against a host that allows one request a second. + let calls = 0 + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + const body = await request.json() as { locations: unknown[] } + calls++ + if (calls === 2) return new HttpResponse(null, { status: 429 }) + return HttpResponse.json(chain(body.locations.length - 1)) + })) + + const run = await valhallaRun(stops(14), 'driving', ['toll']) + + expect(run!.legs).toHaveLength(13) + // First piece, second piece refused, second piece again. + expect(calls).toBe(3) + }) + + it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => { + let calls = 0 + server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) })) + await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull() + expect(calls).toBe(0) + }) + + it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => { + // A day whose legs do not match its stops cannot be assigned to the rail, and + // guessing which stop is missing would put the wrong time against the right place. + server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2)))) + await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull() + }) +}) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts new file mode 100644 index 000000000..4bbdbde7c --- /dev/null +++ b/client/src/components/Map/valhallaRoute.ts @@ -0,0 +1,411 @@ +import { useSettingsStore } from '../../store/settingsStore' +import { decodePolyline } from './transitGeometry' +import { haversineKm } from '../../utils/geo' +import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types' + +/** + * Valhalla, the second routing engine, asked only the questions OSRM cannot answer. + * + * The shipped OSRM hosts are built without excludable road classes: their car profile + * says so in its own Lua ("no memory for classes on demand server"), and `exclude=` + * comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking + * for — drive this without the tolls — has never worked on a default install. + * + * FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there + * avoidance is a runtime weighting rather than a profile that has to be rebuilt. This + * module is the adapter: same question, different engine, answer shaped like the OSRM + * one so the callers do not have to care which engine spoke. + * + * Deliberately NOT a replacement for the OSRM path. Three measured reasons: + * - it caps at ten locations, and following a track sends a day's stops plus up to + * nine anchors, so the bundled requests would start failing; + * - it refuses a point it cannot bind to a road, where OSRM still snaps one. The + * Stephansdom, in a pedestrian zone, answers `No path could be found for input` + * until a search radius is allowed; + * - it prices roads differently. Over ten European legs its drive times sat 5.4 % + * above OSRM's on average and 36 % above on one, and those times feed the arrival + * clocks of a road trip day. Mixing engines inside one chain would move times + * nobody asked to move. + * + * Which leaves exactly the shape this module serves: two points, one avoidance, asked + * on demand, drawn as an offer next to the route rather than replacing it. + */ + +/** The public instance, matching how the OSRM defaults are shipped rather than configured. */ +const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de' + +/** + * How far Valhalla may look for a road under a point, in metres. + * + * Zero is its default and it is the wrong default for TREK's data, which is full of + * museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on + * Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it + * routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it. + */ +const SNAP_RADIUS_M = 50 + +/** TREK's profile names against Valhalla's costing models. */ +const COSTING: Record<'driving' | 'walking' | 'cycling', string> = { + driving: 'auto', + walking: 'pedestrian', + cycling: 'bicycle', +} + +/** + * What a caller can ask to be left out, against the costing option that asks for it. + * + * Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason + * this engine is worth having. Asked to avoid tolls between New York and Boston it + * still returns a tolled road, because there is no untolled way, where OSRM's `exclude` + * would refuse to answer at all. The caller has to read the answer to find out what it + * got, which is what `hasToll` and friends are for. + */ +const AVOID_OPTION: Record = { + motorway: 'use_highways', + toll: 'use_tolls', + ferry: 'use_ferry', +} + +/** The same three classes the rest of the app names; aliased so this file reads on its own. */ +export type AvoidClass = RouteAvoidClass + +/** + * Locations one request may carry. The eleventh answers + * `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400. + */ +const MAX_LOCATIONS = 10 + +/** + * Pause between the pieces of a split day, in milliseconds. + * + * The public instance allows about one request a second per client, and a day long + * enough to be split is exactly the case that would fire two back to back. Matches the + * 1100 ms the road trip rail already leaves between its own days. + */ +const CHUNK_PAUSE_MS = 1100 + +/** + * Extra attempts for one piece of a split day. + * + * One, not a ladder: the common failure here is the public instance's rate limit, which + * a single pause clears, and anything a second attempt cannot fix is not going to be + * fixed by a fourth while somebody waits for their map. + */ +const CHUNK_RETRIES = 1 + +/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */ +export interface ValhallaLeg { + coordinates: [number, number][] + /** Metres. Valhalla is asked in kilometres and converted here. */ + distance: number + /** Seconds. */ + duration: number + /** What the route ACTUALLY has, as opposed to what was asked for. */ + hasToll: boolean + hasHighway: boolean + hasFerry: boolean +} + +/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */ +export interface ValhallaRun { + legs: ValhallaLeg[] + total: ValhallaLeg + /** + * Where each waypoint actually met the road. + * + * Reconstructed from the shape rather than read from a field, because Valhalla echoes + * the coordinates it was GIVEN in `trip.locations` and never says where it bound them. + * The ends of a leg's line are those points: measured against Füssen, its first vertex + * came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same + * request to the digit. Without this the dashed kerb-to-door spur from #2273 would + * quietly disappear from every day routed by this engine. + */ + snapped: SnappedWaypoint[] +} + +/** + * The instance's Valhalla, or null when it should not ask one. + * + * Blank falls back to the public instance, the same reading `routing_base_url` gets — + * with one condition. An operator who filled in their OWN router chose deliberately + * against the public hosts, usually because their instance is not supposed to talk to + * third parties at all, and quietly adding a second public host they never asked for + * would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD + * pipeline answers `exclude` on its own, which is exactly the fallback below. + */ +export function valhallaBase(): string | null { + const settings = useSettingsStore.getState().settings + const configured = settings.valhalla_base_url?.trim() + if (configured) return configured.replace(/\/+$/, '') + return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA +} + +/** True when this engine can be asked at all, so callers can skip building a request. */ +export function valhallaAvailable(): boolean { + return valhallaBase() !== null +} + +/** Hosts that have already proved they are not a Valhalla, by base URL. */ +const notValhalla = new Set() + +/** + * One leg with a road class weighted away, or null when it cannot be had. + * + * Null for every failure, deliberately: this is an offer next to a route that already + * exists, so a host that is down, a rate limit, a point in a field and a genuinely + * connected-only-by-motorway leg all mean the same thing to the caller — there is no + * second way to show. The one failure worth remembering is a host that is not a + * Valhalla at all, because that is true of every leg from now on rather than this one. + */ +export async function valhallaRouteAvoiding( + from: Waypoint, + to: Waypoint, + profile: 'driving' | 'walking' | 'cycling', + avoid: AvoidClass, + signal?: AbortSignal, +): Promise { + const run = await valhallaRun([from, to], profile, [avoid], signal) + return run ? run.total : null +} + +/** + * A whole chain of waypoints, one leg per pair, or null when it cannot be had. + * + * Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled + * into one request precisely so it appears at once rather than trickling in — so a day + * over the cap is cut into runs that overlap by one point and the legs are concatenated. + * That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5] + * came back within two metres and under a second of the same day asked whole, because + * the shared point is routed to and from identically. + * + * The pieces go out one after another with a pause, since a day long enough to need + * splitting is exactly the one that would otherwise fire two requests inside a second + * at a host that allows one. + */ +export async function valhallaRun( + waypoints: Waypoint[], + profile: 'driving' | 'walking' | 'cycling', + avoid: readonly AvoidClass[], + signal?: AbortSignal, +): Promise { + const base = valhallaBase() + if (!base || notValhalla.has(base) || waypoints.length < 2) return null + + const legs: ValhallaLeg[] = [] + const chunks = chunkWaypoints(waypoints) + for (const [index, chunk] of chunks.entries()) { + if (signal?.aborted) return null + if (index > 0) await pause(CHUNK_PAUSE_MS, signal) + // Retried per piece rather than per day. The caller retries the whole task, so a + // rate limit on the last piece of a split day would otherwise throw away the pieces + // that already answered and ask for all of them again — three times over, against a + // host that allows one request a second. + let run = await requestRun(base, chunk, profile, avoid, signal) + for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) { + // Not when the host just proved it is not a Valhalla at all: that answer will not + // change on a second ask, and asking anyway is the request the memo exists to save. + if (notValhalla.has(base)) break + await pause(CHUNK_PAUSE_MS, signal) + run = await requestRun(base, chunk, profile, avoid, signal) + } + // A day is one answer or none. Half a day of legs would be worse than no answer: + // the schedule chains leg times blindly and cannot tell that it is missing some, so + // it would print a complete, plausible, wrong timetable rather than a gap. + if (!run) return null + legs.push(...run.legs) + } + if (legs.length !== waypoints.length - 1) return null + return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) } +} + +/** + * Where each waypoint met the road, taken off the ends of the legs that touch it. + * + * A leg's line starts at the bound position of its first stop and ends at the bound + * position of its second, so stop i is the head of leg i, and the last stop is the tail + * of the last leg. The distance is measured here rather than reported, the same recovery + * `readSnapped` already applies when OSRM leaves the field out. + */ +function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] { + return waypoints.map((waypoint, i) => { + const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates + const at = i < legs.length ? line[0] : line[line.length - 1] + const asked: [number, number] = [waypoint.lat, waypoint.lng] + return { + asked, + at, + meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000, + } + }) +} + +/** Runs of at most ten points, each starting on the one the previous ended at. */ +function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] { + if (waypoints.length <= MAX_LOCATIONS) return [waypoints] + const chunks: Waypoint[][] = [] + for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) { + chunks.push(waypoints.slice(start, start + MAX_LOCATIONS)) + } + return chunks +} + +function pause(ms: number, signal?: AbortSignal): Promise { + return new Promise(resolve => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true }) + }) +} + +/** One request, at most ten locations. */ +async function requestRun( + base: string, + waypoints: Waypoint[], + profile: 'driving' | 'walking' | 'cycling', + avoid: readonly AvoidClass[], + signal?: AbortSignal, +): Promise | null> { + const costing = COSTING[profile] + // All of them in one request: the options are independent weightings, so asking to + // leave out tolls and ferries together is one question rather than two routes to + // reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted + // away together answered 656.9 km against 339.2 km for the plain route. + const options: Record = {} + for (const cls of avoid) options[AVOID_OPTION[cls]] = 0 + const body = { + locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })), + costing, + costing_options: { [costing]: options }, + directions_options: { units: 'kilometers' }, + } + + try { + const response = await fetch(`${base}/route`, { + method: 'POST', + // A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a + // client to identify itself with. It costs a preflight, which is answered. + headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, + body: JSON.stringify(body), + signal, + }) + if (!response.ok) { + // 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake, + // or a proxy that swallowed the path. Every other status is about this request: + // 400 carries the real refusals (unroutable point, over 1500 km apart) and 429 + // is the public instance's one-per-second. + if (response.status === 404 || response.status === 405) notValhalla.add(base) + return null + } + return runFrom(await response.json()) + } catch { + return null + } +} + +/** + * Several legs read as one, the way a caller that only wants the whole drive sees it. + * + * The seam matters more than it looks. Every leg ends where the next begins, so a plain + * concatenation repeats that vertex — and this line is not only drawn: a via dropped on + * the route is projected onto it to work out which leg it belongs after, and that index + * is written to the database. A repeated point can put the via on the wrong leg, which + * is a stored wrong answer rather than an invisible one. + */ +function joinLegs(legs: ValhallaLeg[]): ValhallaLeg { + const coordinates: [number, number][] = [] + for (const leg of legs) { + const last = coordinates[coordinates.length - 1] + const first = leg.coordinates[0] + const continues = last && first && last[0] === first[0] && last[1] === first[1] + coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates)) + } + return { + coordinates, + distance: legs.reduce((sum, l) => sum + l.distance, 0), + duration: legs.reduce((sum, l) => sum + l.duration, 0), + // One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day + // with a single toll booth in it would be labelled toll-free. + hasToll: legs.some(l => l.hasToll), + hasHighway: legs.some(l => l.hasHighway), + hasFerry: legs.some(l => l.hasFerry), + } +} + +/** + * The Valhalla answer as a leg, or null when it is not one. + * + * Split out because it is the whole of the format knowledge and the only part worth + * testing without a network: the native shape, not `format=osrm`. That flag exists and + * would have kept the response side unchanged, but it drops `has_toll` — the one field + * this is all for — and carries every intersection instead, which measured 5.7 times + * larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one. + */ +/** + * The answer read leg by leg, which is what a whole day needs. + * + * Valhalla gives every leg its own summary, so a chain comes back already divided the + * way a road trip day wants it: this stop to the next, with its own distance, its own + * drive time, and its own honest `has_toll`. Reading only the trip summary would flatten + * a day into one number and lose the per-leg times the schedule is built from. + * + * The fallback to the trip summary is for a single leg only. Where several legs carry no + * summary of their own there is no honest way to divide one total between them, and a + * guess here would arrive as a wrong arrival time rather than as a missing one. + */ +export function runFrom(data: unknown): Omit | null { + const trip = tripOf(data) + if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null + + const legs: ValhallaLeg[] = [] + for (const leg of trip.legs) { + if (typeof leg.shape !== 'string') return null + const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined) + if (!summary) return null + // Precision 6, verified against a known point: decoded as 5 the first vertex of a + // Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193. + const measure = measured(summary, decodePolyline(leg.shape, 6)) + if (!measure) return null + legs.push(measure) + } + return { legs, total: joinLegs(legs) } +} + +interface ValhallaSummary { [key: string]: number | boolean | undefined } + +function tripOf(data: unknown) { + return (data as { + trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary } + })?.trip +} + +/** A summary and a shape as a leg, or null when the summary carries no numbers. */ +function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null { + if (!coordinates.length) return null + const length = Number(summary.length) + const time = Number(summary.time) + if (!Number.isFinite(length) || !Number.isFinite(time)) return null + + return { + coordinates, + // Asked in kilometres, answered in kilometres, handed on in metres because that is + // what every caller already reads off an OSRM route. + distance: length * 1000, + duration: time, + hasToll: summary.has_toll === true, + hasHighway: summary.has_highway === true, + hasFerry: summary.has_ferry === true, + } +} + +/** + * Whether a route actually avoids what it was asked to avoid. + * + * The reason this function exists rather than a boolean on the request: `use_tolls: 0` + * is a weighting, so between two points with no untolled connection Valhalla answers a + * tolled route and reports it honestly. Labelling that "No tolls" would be a lie on + * screen, and the flags are in the answer precisely so nobody has to guess. + */ +export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean { + if (avoid === 'toll') return !leg.hasToll + if (avoid === 'motorway') return !leg.hasHighway + return !leg.hasFerry +} diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index f7c84b720..da58cb498 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,11 +1,13 @@ import React, { useState } from 'react' -import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react' +import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react' import Modal from '../shared/Modal' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { convertDistance, formatDistance } from '../../utils/units' -import { formatDurationShort } from './roadtripModel' -import type { DistanceUnit } from '../../types' +import { formatDurationShort, parseAvoid, serializeAvoid, AVOIDABLE } from './roadtripModel' +import { valhallaAvailable } from '../Map/valhallaRoute' +import ToggleSwitch from '../Settings/ToggleSwitch' +import type { DistanceUnit, RouteAvoidClass } from '../../types' import { FS } from './typeScale' /** @@ -76,9 +78,39 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: { ) } +/** + * One class the drive should leave out, with the switch that asks for it. + * + * Disabled rather than hidden when there is no engine that can answer: an operator who + * pointed the instance at their own OSRM has no second engine, and a switch that flips + * and changes nothing is worse than one that says why it cannot. + */ +function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: { + icon: typeof Coins + label: string + on: boolean + disabled: boolean + onToggle: () => void +}): React.ReactElement { + return ( +
+ + {label} + {disabled + ? {'—'} + : } +
+ ) +} + export default function RoadtripLimitsCard({ onSave }: { - /** Persists one setting. Absent leaves the dialog read-only. */ - onSave?: (key: string, value: number) => void + /** + * Persists one setting. Absent leaves the dialog read-only. + * + * A string as well as a number since the avoidance is stored as a comma list: one + * decision with three parts, which goes to the router as one request either way. + */ + onSave?: (key: string, value: number | string) => void }): React.ReactElement { const { t } = useTranslation() const settings = useSettingsStore(s => s.settings) @@ -89,6 +121,23 @@ export default function RoadtripLimitsCard({ onSave }: { const dayMinutes = settings.roadtrip_day_minutes const rangeKm = settings.roadtrip_range_km + // Parsed rather than trusted: a per-user setting gets no server-side validation, and + // an unknown word here would become a costing option the router does not have. + const avoiding = parseAvoid(settings.roadtrip_avoid) + // No second engine, no avoidance. An instance pointed at its own OSRM has one, and its + // car profile is built without excludable classes on every public host. + const canAvoid = valhallaAvailable() + const toggleAvoid = (cls: RouteAvoidClass) => { + const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls] + onSave?.('roadtrip_avoid', serializeAvoid(next)) + } + + const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [ + { cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') }, + { cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') }, + { cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') }, + ] + // Kilometres in storage, the traveller's own unit on screen. Without the round trip an // imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a // third too early for ever after. @@ -107,6 +156,12 @@ export default function RoadtripLimitsCard({ onSave }: { legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null, dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null, rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null, + // The avoidance rides here too, and as ONE badge rather than three: it is a single + // decision, the three icons already say which parts of it are on, and a fourth, + // fifth and sixth badge would wrap the row onto a second line in a narrow rail. + avoiding.length && canAvoid + ? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) } + : null, ].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[] return ( @@ -193,6 +248,35 @@ export default function RoadtripLimitsCard({ onSave }: { rail does not model one. A limit called "travel time per day" over that number would be a lie that looks tidy. */}

{t('roadtrip.limit.hint')}

+ + {/* A heading and a rule, because six rows in one list stop reading as a form + and start reading as a wall. The three above are numbers that warn; the + three below change the road itself, which is a different kind of setting + and worth the separation. */} +
+

+ {t('roadtrip.avoid.section')} +

+
+ {AVOID_ROWS.map(({ cls, icon, label }) => ( + toggleAvoid(cls)} + /> + ))} +
+ {/* "Where possible" is doing real work in the label, so it is spelled out + here: the router weights a class down rather than banning it, and on a + drive with no way round one the road still uses it. The day says so + itself when that happens, which is the only honest way to offer this. */} +

+ {canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')} +

+
) : null} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 5b8906147..fd44ac820 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -887,6 +887,21 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })} + {/* The other half of "where possible". The setting is a weighting, so a day + with no untolled crossing comes back on the toll road — and the only thing + worse than not avoiding it is not avoiding it silently, which reads as the + switch being broken. Among the day's facts because that is what it is: a + fact about this day's roads, not a warning about the plan. */} + {day.avoidMissed?.length ? ( + + + + {t('roadtrip.avoid.missed', { + classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '), + })} + + + ) : null} {/* Among the day's facts rather than beside its title, because "which road this day takes" is one of them. Tinted once the day carries vias: the rail draws none of them, so this badge is the only place a drive shaped by hand differs diff --git a/client/src/components/Roadtrip/alternativeOverlays.test.ts b/client/src/components/Roadtrip/alternativeOverlays.test.ts index 7cc996f28..9aa94da40 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.test.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.test.ts @@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator' * reason this is not simply the midpoint. */ -const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' } +const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' } /** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */ function line(n: number, lat: number): [number, number][] { diff --git a/client/src/components/Roadtrip/alternativeOverlays.ts b/client/src/components/Roadtrip/alternativeOverlays.ts index ff9e82f0f..df97f689f 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.ts @@ -83,7 +83,7 @@ function mostDistinctPoint( */ export function buildAlternativeOverlays( routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined, - labels: { fastest: string; current: string; noMotorway: string; noToll: string }, + labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string }, ): AlternativeOverlay[] { if (!routes?.length) return [] // A single answer is not a choice; drawing it would just double the route already there. @@ -114,7 +114,9 @@ export function buildAlternativeOverlays( ? labels.noMotorway : route.avoids === 'toll' ? labels.noToll - : index === quickest ? labels.fastest : '', + : route.avoids === 'ferry' + ? labels.noFerry + : index === quickest ? labels.fastest : '', duration: route.duration, distance: route.distance, slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)), diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 3b093ef78..753eee654 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -4,6 +4,8 @@ * on its own and reused by the map, the rail and (later) the MCP tool. */ +import type { RouteAvoidClass } from '../../types' + /** * The stop kinds that interrupt a drive rather than end it. * @@ -670,3 +672,26 @@ export function reanchorAfterReorder( export function isEmptyReanchoring(r: Reanchoring): boolean { return r.vias.length === 0 && r.remove.length === 0 } + +/** + * The road classes a stored setting asks to leave out, validated. + * + * Parsed rather than trusted. A per-user setting has no server-side validation at all — + * the write route stores any key with any value and says so — so an unknown word here + * would travel straight into a costing option the router does not have. Unknown entries + * are dropped, the order is fixed so two equal settings produce one cache key, and + * anything that is not a non-empty list reads as "route normally". + */ +export function parseAvoid(raw: unknown): RouteAvoidClass[] { + if (typeof raw !== 'string' || !raw.trim()) return [] + const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase())) + return AVOIDABLE.filter(cls => asked.has(cls)) +} + +/** Every class that can be avoided, in the order they are offered and stored. */ +export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry'] + +/** The setting value for a set of classes, in the fixed order. */ +export function serializeAvoid(classes: readonly RouteAvoidClass[]): string { + return AVOIDABLE.filter(cls => classes.includes(cls)).join(',') +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 753b58fa2..77728647f 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,11 +3,10 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula import { resolveLegMode } from '../Planner/legMode' import { computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, - type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, -} from './roadtripModel' + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid,} from './roadtripModel' import { projectOntoRoute } from './corridor' import { useSettingsStore } from '../../store/settingsStore' -import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' +import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' import { spurFor } from './accessSpur' import type { RoadtripVia } from '@trek/shared' @@ -53,6 +52,15 @@ export interface RoadtripDay { * stop. Kept out of `RouteSegment` because that type is read by half the map. */ legVias: RouteVia[][] + /** + * Classes this day was asked to leave out and could not. + * + * Empty when the drive got what it asked for, and empty when nothing was asked. The + * switch says "where possible", and this is what makes that honest rather than a + * hedge: a drive with no untolled crossing comes back on the toll road, and the day + * has to be able to say so instead of wearing a label the road disproves. + */ + avoidMissed?: RouteAvoidClass[] /** * The roads actually driven that day, as [lat, lng] — not the straight lines between * stops. Anything asking "what is along this day" has to use this: between Hamburg and @@ -247,12 +255,25 @@ export function useRoadtripRoutes( * filing it twice would draw the spur twice. */ const [snapByDay, setSnapByDay] = useState>>({}) + /** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */ + const [missedByDay, setMissedByDay] = useState>({}) const [loading, setLoading] = useState(false) const abortRef = useRef(null) const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes) const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes) const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km) + /** + * Road classes to weight away, as the settings row stores them: a comma list. + * + * Parsed against the known classes rather than trusted, because there is no + * server-side validation for a per-user setting — the write route stores any key with + * any value — and an unknown word here would become a costing option the router does + * not have. + */ + const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid) + const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) + const avoidKey = avoid.join(',') // Zero and absent both mean "no limit": zero is a legal thing to type and says the // same thing, so it is folded here rather than guarded at every reading. const limits = useMemo( @@ -327,6 +348,7 @@ export function useRoadtripRoutes( if (!plan.length) { setLegsByDay({}) setSnapByDay({}) + setMissedByDay({}) setLoading(false) return } @@ -339,6 +361,7 @@ export function useRoadtripRoutes( const collected: Record> = {} const collectedSnaps: Record> = {} + const collectedMisses: Record = {} const tasks: (() => Promise)[] = [] for (const day of plan) { @@ -379,7 +402,7 @@ export function useRoadtripRoutes( try { const r = await calculateRouteWithLegs( waypoints, - { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId }, + { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid }, ) // Where each stop ended up. stopAt[i] is that stop's waypoint index, so the // vias threaded in between are skipped: a via is a shape handle, not a @@ -456,6 +479,7 @@ export function useRoadtripRoutes( if (controller.signal.aborted) return setLegsByDay({ ...collected }) setSnapByDay({ ...collectedSnaps }) + setMissedByDay({ ...collectedMisses }) // Only pace what actually went out. RouteCalculator answers a repeat from its // cache in well under a millisecond, and switching back into road trip mode is // all repeats — waiting a second between those made a warm view feel broken. @@ -468,7 +492,8 @@ export function useRoadtripRoutes( return () => controller.abort() // planKey stands in for `plan`: same geometry, same legs. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [planKey, routeProfile, distanceUnit, tripId]) + // avoidKey, not `avoid`: a fresh array every render would re-route on every render. + }, [planKey, routeProfile, distanceUnit, tripId, avoidKey]) return useMemo(() => { const lines: [number, number][][] = [] diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9139bf098..b180a6fc2 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -772,7 +772,7 @@ export function useTripPlanner() { * per-user preferences on the settings table, the same path the map provider and the * distance unit take, and they are read locally the moment they change. */ - const saveRoadtripLimit = useCallback(async (key: string, value: number) => { + const saveRoadtripLimit = useCallback(async (key: string, value: number | string) => { try { await updateSettings({ [key]: value } as Partial) } catch { @@ -1054,6 +1054,7 @@ export function useTripPlanner() { current: t('roadtrip.alt.current'), noMotorway: t('roadtrip.alt.noMotorway'), noToll: t('roadtrip.alt.noToll'), + noFerry: t('roadtrip.alt.noFerry'), }), [routeAlternatives.open, t], ) diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index b3450c1d7..d9c2f0169 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -61,6 +61,9 @@ export const DEFAULT_SETTINGS: Settings = { map_tile_url: '', // Empty = the public routing hosts TREK ships with. routing_base_url: '', + // Empty = the public Valhalla, unless routing_base_url names an own router, in which + // case no second engine is asked at all. See valhallaBase(). + valhalla_base_url: '', dark_mode: false, // Empty = no personal display currency, so Costs falls back to the trip's own. default_currency: '', diff --git a/client/src/types.ts b/client/src/types.ts index 72f55b0ef..67a48079c 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -115,6 +115,12 @@ export interface Settings { * FOSSGIS hosts, which allow about one request a second. */ routing_base_url?: string + /** + * Base URL of the Valhalla asked for the avoidance questions OSRM cannot answer. + * Undefined means the public FOSSGIS instance TREK ships with; empty means an + * operator turned the second engine off and "other ways" goes back to OSRM alone. + */ + valhalla_base_url?: string dark_mode: boolean | string /** Display currency for Costs. Empty/null = follow each trip's own currency. */ default_currency: string | null @@ -143,6 +149,16 @@ export interface Settings { roadtrip_leg_minutes?: number roadtrip_day_minutes?: number roadtrip_range_km?: number + /** + * Road classes the drive should leave out where it can, as a comma list + * ("toll,ferry"). Absent means route normally, which is the right meaning for absent: + * the settings row does not exist until somebody turns one of them on, and the first + * paint of every session reads this before the settings have loaded. + * + * A list rather than three booleans because it is one decision with three parts, and + * because it goes to the router as one request either way. + */ + roadtrip_avoid?: string /** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */ carto_api_key?: string mapbox_access_token?: string @@ -228,8 +244,20 @@ export interface RouteWithLegs { vias?: RouteVia[] /** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */ snapped?: SnappedWaypoint[] + /** + * What was asked to be left out, and what the road actually left out. + * + * The two differ, and that is the point. Valhalla weights a class away rather than + * banning it, so a drive with no untolled connection comes back on a toll road and + * says so. Present only on a route that was asked to avoid something, so `undefined` + * means the question was never put rather than "avoided nothing". + */ + avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[] } } +/** A road class a route can be asked to leave out. */ +export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry' + export interface RouteResult { coordinates: [number, number][] distance: number diff --git a/client/tests/helpers/msw/handlers/external.ts b/client/tests/helpers/msw/handlers/external.ts index 8cd9c922f..8a2394302 100644 --- a/client/tests/helpers/msw/handlers/external.ts +++ b/client/tests/helpers/msw/handlers/external.ts @@ -15,4 +15,19 @@ import { http, HttpResponse } from 'msw'; export const externalHandlers = [ http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])), http.get('https://api.frankfurter.dev/v2/currencies', () => HttpResponse.json({})), + /** + * The public Valhalla, answered as unreachable by default. + * + * It is a shipped default rather than a configured host, so every alternatives test + * reaches it without asking for it, and the warn-then-perform path above sent those + * requests to the real FOSSGIS instance — a test asserting a fixed divergence point + * got a genuine one off the A2 instead, which fails differently on every OSM update. + * + * 503 rather than 404 on purpose: the adapter remembers a 404 as "this host is not a + * Valhalla" in module state, which would then leak into whichever file ran next. A + * 503 is about this request alone, so it lands as the null every caller already + * handles and the OSRM path takes over exactly as it did before. A test about the + * Valhalla path overrides this with server.use(). + */ + http.post('https://valhalla1.openstreetmap.de/route', () => new HttpResponse(null, { status: 503 })), ]; diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index e80f76689..345bdf55e 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -86,12 +86,18 @@ export async function buildApp(): Promise { // config instead of reading process.env itself. const http = app.get>(httpConfig.KEY); // Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or - // the browser blocks every request to it without an error the app could report. + // the browser blocks every request to it without an error the app could report. Both + // engines go through the same door — the second one answers the avoidance questions + // the first cannot, and is blocked just as silently when the policy leaves it out. const settings = app.get(SettingsService, { strict: false }); - const routingBase = settings?.getAdminUserDefaults()?.routing_base_url; + const defaults = settings?.getAdminUserDefaults(); + const asUrl = (value: unknown) => (typeof value === 'string' ? value : null); applyGlobalMiddleware(instance, { http, - extraConnectSrc: routingCspOrigins([typeof routingBase === 'string' ? routingBase : null]), + extraConnectSrc: routingCspOrigins([ + asUrl(defaults?.routing_base_url), + asUrl(defaults?.valhalla_base_url), + ]), }); // Same pre-init consumption bridge as httpConfig above: the StorageService // instance is resolvable before init, and the handlers only *register* here — diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index eeb805f59..7094342e1 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -245,6 +245,12 @@ export function applyGlobalMiddleware( "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", + // The second routing engine, shipped as a default the same way the OSRM hosts + // above are. It is asked only when a leg should avoid tolls, motorways or a + // ferry — which the OSRM hosts answer with HTTP 400, because their car profile + // carries no excludable classes. Origin only, no path: unlike OSRM this one is + // a POST to /route and would grow more endpoints if isochrones ever land. + "https://valhalla1.openstreetmap.de", "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com", "https://tiles.openfreemap.org", // A self-hosted routing engine, when the instance has one configured. Without diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index bfd41c87b..0e77641dc 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -96,6 +96,7 @@ export const MANAGED_LOCKED_SETTING_KEYS = [ 'smtp_skip_tls_verify', 'smtp_user', 'unsplash_api_key', + 'valhalla_base_url', 'webauthn_origins', 'webauthn_rp_id', ] as const; diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index 53dd6b584..46df58797 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -42,6 +42,18 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [ * once at boot, and a router the policy does not name fails silently. */ 'routing_base_url', + /** + * The second engine, asked only what OSRM cannot answer: drive this leg without the + * tolls, the motorway or the ferry. The shipped OSRM hosts refuse `exclude` outright, + * so on a default install that question has never had an answer. + * + * Same class as the key above and for the same reason: it names an origin the BROWSER + * connects to, so it has to be in the boot-time connect-src, and changing it needs a + * restart. Empty falls back to the public Valhalla, unless routing_base_url names an + * own engine — an operator who chose their own router is not given a public third + * party they never asked for. + */ + 'valhalla_base_url', // CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and // the key is per-instance rather than per-person: defaultable so one admin // value clears the watermark for everybody at once. @@ -100,7 +112,7 @@ const BOOLEAN_KEYS = new Set(['blur_booking_codes', 'mapbox_3d_e * on the next save instead of blocking it. */ export function isAdminOnlyEndpointSetting(key: string, value: unknown): boolean { - if (key === 'llm_base_url' || key === 'routing_base_url') { + if (key === 'llm_base_url' || key === 'routing_base_url' || key === 'valhalla_base_url') { return typeof value === 'string' && value.trim() !== ''; } if (key === 'llm_provider') return value === 'local'; diff --git a/server/tests/e2e/settings.e2e.test.ts b/server/tests/e2e/settings.e2e.test.ts index 85aedd3d5..866b4a8d5 100644 --- a/server/tests/e2e/settings.e2e.test.ts +++ b/server/tests/e2e/settings.e2e.test.ts @@ -175,6 +175,23 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { expect(countRows()).toBe(0); }); + it('PUT 403 for a non-admin naming the second routing engine', async () => { + // The Valhalla is the same class as the OSRM above and fails the same way: its + // origin has to be in the boot-time connect-src, which is assembled from the + // admin default alone. A personal row would be read by the route calculator and + // then refused by the browser, so "other ways" would quietly stop offering any. + const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) + .send({ key: 'valhalla_base_url', value: 'https://valhalla.example.org' }); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Admin access required' }); + expect(countRows()).toBe(0); + }); + + it('PUT lets a non-admin clear the second routing engine', async () => { + expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) + .send({ key: 'valhalla_base_url', value: '' })).status).toBe(200); + }); + it('PUT is unaffected for a non-LLM key with the same value', async () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) .send({ key: 'start_page', value: 'local' }); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index ea1e14dea..18abf4d46 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -52,6 +52,24 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { }); }); +describe('global CSP: the second routing engine', () => { + it('allows the public Valhalla, which is a shipped default like the OSRM hosts', async () => { + // It is asked only when a leg should avoid tolls, a motorway or a ferry — the + // question the OSRM hosts answer with HTTP 400, because their car profile carries + // no excludable classes. Left out here it fails the way CSP always fails a fetch: + // silently, with the switch working and no tile of an answer arriving. + expect(await connectSrcSources()).toContain('https://valhalla1.openstreetmap.de'); + }); + + it('names the origin without a path, because it is a POST to /route', async () => { + // The OSRM entries carry `/route/v1/` because a source with a path matches that + // path alone, and OSRM is only ever asked there. Valhalla is a different shape and + // would grow endpoints (isochrones, map matching), so the origin is the right unit. + const sources = await connectSrcSources(); + expect(sources).not.toContain('https://valhalla1.openstreetmap.de/route'); + }); +}); + describe('global CSP: the imagery host (#2307)', () => { it('allows server.arcgisonline.com, which a GL map reaches through fetch', async () => { // The gap hid behind Leaflet for as long as Leaflet was the only renderer to @@ -166,6 +184,17 @@ describe('routingCspOrigins', () => { 'https://osrm.example.org/table/v1/driving', ])).toEqual(['https://osrm.example.org']); }); + + it('CSP-ROUTING-006: both engines get named, because both are reached from the browser', () => { + // bootstrap hands in the OSRM default and the Valhalla default together. An + // instance that configures its own Valhalla and leaves OSRM public still has to + // have that host in the policy, and the other way round. + expect(routingCspOrigins([ + 'https://osrm.example.org/route/v1/driving', + 'https://valhalla.example.org', + ])).toEqual(['https://osrm.example.org', 'https://valhalla.example.org']); + expect(routingCspOrigins([null, 'https://valhalla.example.org'])).toEqual(['https://valhalla.example.org']); + }); }); describe('connect-src with a self-hosted router', () => { diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts index a8b2391b8..3ba5fac13 100644 --- a/server/tests/unit/nest/managed-keys.test.ts +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -70,6 +70,7 @@ describe('managed key assignment', () => { 'smtp_skip_tls_verify', 'smtp_user', 'unsplash_api_key', + 'valhalla_base_url', 'webauthn_origins', 'webauthn_rp_id', ]); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 6fc2908ec..986ea8e64 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'شحن', 'roadtrip.stop.offRoad': '{distance} من الطريق', 'roadtrip.via.plugin': 'توقف في الطريق', - 'roadtrip.limit.title': 'حدود القيادة', + 'roadtrip.limit.title': 'إعدادات القيادة', 'roadtrip.limit.legLabel': 'أطول قيادة متواصلة', 'roadtrip.limit.dayLabel': 'القيادة في اليوم', 'roadtrip.limit.rangeLabel': 'المدى بخزان واحد', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} زيادة', 'roadtrip.limit.range': '{distance} منذ آخر تعبئة', 'roadtrip.limit.none': 'غير محددة', + 'roadtrip.avoid.section': 'التجنب قدر الإمكان', + 'roadtrip.avoid.toll': 'الطرق ذات الرسوم', + 'roadtrip.avoid.motorway': 'الطرق السريعة', + 'roadtrip.avoid.ferry': 'العبّارات', + 'roadtrip.avoid.hint': 'هذا ترجيح وليس منعًا. عندما لا تجد الرحلة طريقًا بديلًا فإنها تستخدمه، ويذكر اليوم ذلك.', + 'roadtrip.avoid.unavailable': 'غير متاح: هذه النسخة تستخدم محركها الخاص، وهو لا يستطيع استبعاد فئة طرق.', + 'roadtrip.avoid.badge': 'تجنُّب {count}', + 'roadtrip.avoid.missed': 'لا مفر من {classes}', + 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'المغادرة', 'roadtrip.alt.noMotorway': 'بدون طريق سريع', 'roadtrip.alt.noToll': 'بدون رسوم', + 'roadtrip.alt.noFerry': 'بدون عبّارة', 'roadtrip.summary.partial': 'لا يزال يجري حساب بقية الطريق', }; export default roadtrip; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 82a58094d..287f1fd24 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -25,6 +25,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا', 'settings.routingBase': 'محرك مسارات خاص', 'settings.routingBaseHint': 'خادم OSRM خاص بك. اتركه فارغًا لاستخدام الخوادم العامة التي تسمح بطلب واحد تقريبًا في الثانية — يكفي ليوم واحد، وضيّق لرحلة برية. يسري بعد إعادة تشغيل الخادم.', + 'settings.valhallaBase': 'خادم Valhalla خاص بك', + 'settings.valhallaBaseHint': 'خادم Valhalla خاص بك. يُسأل عندما يجب أن تتجنب المرحلة الرسوم أو الطرق السريعة أو العبّارات — الخوادم العامة لـ OSRM لا تستطيع الإجابة عن ذلك. الفراغ يستخدم Valhalla العام من FOSSGIS، إلا إذا حُدِّد أعلاه محرك توجيه خاص بك، فلا يُسأل أي محرك ثانٍ. يسري بعد إعادة تشغيل الخادم.', 'settings.mapHint': 'قالب URL لبلاطات الخريطة', 'settings.mapProvider': 'مزود الخريطة', 'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 370d387eb..792bfe69b 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Carregamento', 'roadtrip.stop.offRoad': '{distance} da estrada', 'roadtrip.via.plugin': 'Parada no caminho', - 'roadtrip.limit.title': 'Limites de direção', + 'roadtrip.limit.title': 'Configurações de direção', 'roadtrip.limit.legLabel': 'Trecho mais longo seguido', 'roadtrip.limit.dayLabel': 'Condução por dia', 'roadtrip.limit.rangeLabel': 'Autonomia por tanque', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} a mais', 'roadtrip.limit.range': '{distance} desde o último abastecimento', 'roadtrip.limit.none': 'Nenhum definido', + 'roadtrip.avoid.section': 'Evitar quando possível', + 'roadtrip.avoid.toll': 'Pedágios', + 'roadtrip.avoid.motorway': 'Rodovias', + 'roadtrip.avoid.ferry': 'Balsas', + 'roadtrip.avoid.hint': 'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.', + 'roadtrip.avoid.unavailable': 'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.', + 'roadtrip.avoid.badge': '{count} evitados', + 'roadtrip.avoid.missed': '{classes} inevitável', + 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', 'roadtrip.stop.none': 'Um destino', 'roadtrip.stay.mode.pause': 'Pausa', 'roadtrip.stay.mode.night': 'Pernoite', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Partida', 'roadtrip.alt.noMotorway': 'Sem rodovia', 'roadtrip.alt.noToll': 'Sem pedagio', + 'roadtrip.alt.noFerry': 'Sem balsa', 'roadtrip.summary.partial': 'O restante do trajeto ainda está sendo calculado', }; export default roadtrip; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index ac9c93725..892e35fa5 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)', 'settings.routingBase': 'Servidor de rotas próprio', 'settings.routingBaseHint': 'Uma instância própria do OSRM. Vazio usa os servidores públicos, que permitem cerca de uma requisição por segundo — suficiente para um dia, apertado para uma viagem de carro. Passa a valer após reiniciar o servidor.', + 'settings.valhallaBase': 'Instância Valhalla própria', + 'settings.valhallaBaseHint': 'Uma instância Valhalla própria. Ela é consultada quando um trecho deve evitar pedágios, rodovias ou balsas — os servidores OSRM públicos não conseguem responder isso. Vazio usa a Valhalla pública da FOSSGIS, a não ser que acima esteja indicado um motor de rotas próprio; nesse caso nenhum segundo motor é consultado. Só vale após reiniciar o servidor.', 'settings.mapHint': 'URL do modelo de blocos do mapa', 'settings.mapProvider': 'Provedor de mapa', 'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 324bdcdb7..f4d692377 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Càrrega', 'roadtrip.stop.offRoad': '{distance} des de la carretera', 'roadtrip.via.plugin': 'Parada al camí', - 'roadtrip.limit.title': 'Límits de conducció', + 'roadtrip.limit.title': 'Preferències de conducció', 'roadtrip.limit.legLabel': 'Trajecte més llarg seguit', 'roadtrip.limit.dayLabel': 'Conducció per dia', 'roadtrip.limit.rangeLabel': 'Autonomia per dipòsit', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de més', 'roadtrip.limit.range': '{distance} des de l’última càrrega', 'roadtrip.limit.none': 'Cap definit', + 'roadtrip.avoid.section': 'Evita quan sigui possible', + 'roadtrip.avoid.toll': 'Peatges', + 'roadtrip.avoid.motorway': 'Autopistes', + 'roadtrip.avoid.ferry': 'Ferris', + 'roadtrip.avoid.hint': 'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.', + 'roadtrip.avoid.unavailable': 'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.', + 'roadtrip.avoid.badge': '{count} evitats', + 'roadtrip.avoid.missed': '{classes} inevitable', + 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', 'roadtrip.stop.none': 'Una destinació', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Sortida', 'roadtrip.alt.noMotorway': 'Sense autopista', 'roadtrip.alt.noToll': 'Sense peatges', + 'roadtrip.alt.noFerry': 'Sense ferri', 'roadtrip.summary.partial': 'Encara s’està calculant la resta del trajecte', }; export default roadtrip; diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 55267c6c0..c415ad51f 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -16,6 +16,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Deixa-ho buit per a OpenStreetMap (per defecte)', 'settings.routingBase': 'Motor de rutes propi', 'settings.routingBaseHint': 'Una instància pròpia d’OSRM. Buit fa servir els servidors públics, que permeten aproximadament una petició per segon: prou per a un dia, just per a un viatge llarg. Té efecte després de reiniciar el servidor.', + 'settings.valhallaBase': 'Instància Valhalla pròpia', + 'settings.valhallaBaseHint': 'Una instància Valhalla pròpia. Es consulta quan una etapa ha d’evitar peatges, autopistes o ferris: els servidors OSRM públics no poden respondre-ho. Buit fa servir el Valhalla públic de FOSSGIS, tret que a dalt s’indiqui un motor de rutes propi, cas en què no es consulta cap segon motor. Té efecte després de reiniciar el servidor.', 'settings.mapHint': "Plantilla d'URL per als mosaics del mapa", 'settings.mapProvider': 'Proveïdor de mapa', 'settings.mapProviderHint': 'Afecta els mapes de Trip Planner i Journey. Atles sempre utilitza Leaflet.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index cc0645423..5782bfab8 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Nabíjení', 'roadtrip.stop.offRoad': '{distance} od silnice', 'roadtrip.via.plugin': 'Zastávka po cestě', - 'roadtrip.limit.title': 'Limity jízdy', + 'roadtrip.limit.title': 'Nastavení jízdy', 'roadtrip.limit.legLabel': 'Nejdelší jízda v kuse', 'roadtrip.limit.dayLabel': 'Jízda za den', 'roadtrip.limit.rangeLabel': 'Dojezd na jednu nádrž', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'o {time} více', 'roadtrip.limit.range': '{distance} od posledního tankování', 'roadtrip.limit.none': 'Nenastaveno', + 'roadtrip.avoid.section': 'Vyhnout se, pokud to jde', + 'roadtrip.avoid.toll': 'Mýtné silnice', + 'roadtrip.avoid.motorway': 'Dálnice', + 'roadtrip.avoid.ferry': 'Trajekty', + 'roadtrip.avoid.hint': 'Jde o penalizaci, ne o zákaz. Když se jízda nemá jak vyhnout, použije to a den to řekne.', + 'roadtrip.avoid.unavailable': 'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.', + 'roadtrip.avoid.badge': '{count} vynecháno', + 'roadtrip.avoid.missed': '{classes} nelze objet', + 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', 'roadtrip.stop.none': 'Cíl cesty', 'roadtrip.stay.mode.pause': 'Pauza', 'roadtrip.stay.mode.night': 'Nocleh', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Odjezd', 'roadtrip.alt.noMotorway': 'Bez dalnice', 'roadtrip.alt.noToll': 'Bez mytneho', + 'roadtrip.alt.noFerry': 'Bez trajektu', 'roadtrip.summary.partial': 'Zbytek trasy se ještě počítá', }; export default roadtrip; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 9dfdb71c1..17e9d0572 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)', 'settings.routingBase': 'Vlastní směrovací server', 'settings.routingBaseHint': 'Vlastní instance OSRM. Prázdné použije veřejné servery s limitem asi jednoho požadavku za sekundu — na den to stačí, na road trip je to málo. Projeví se po restartu serveru.', + 'settings.valhallaBase': 'Vlastní instance Valhalla', + 'settings.valhallaBaseHint': 'Vlastní instance Valhalla. Ptáme se jí, když má úsek objet mýtné, dálnice nebo trajekty — veřejné servery OSRM to neumí zodpovědět. Prázdné použije veřejnou Valhallu od FOSSGIS, ledaže je výše uveden vlastní směrovací engine; pak se žádný druhý engine neptá. Projeví se po restartu serveru.', 'settings.mapHint': 'URL šablony pro mapové dlaždice', 'settings.mapProvider': 'Poskytovatel mapy', 'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 7b248926f..fcf9d6679 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} von der Straße', 'roadtrip.via.plugin': 'Halt unterwegs', - 'roadtrip.limit.title': 'Fahrgrenzen', + 'roadtrip.limit.title': 'Fahreinstellungen', 'roadtrip.limit.legLabel': 'Längste Fahrt am Stück', 'roadtrip.limit.dayLabel': 'Fahrzeit pro Tag', 'roadtrip.limit.rangeLabel': 'Reichweite pro Tankfüllung', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} drüber', 'roadtrip.limit.range': '{distance} seit dem letzten Tanken', 'roadtrip.limit.none': 'Keine gesetzt', + 'roadtrip.avoid.section': 'Möglichst meiden', + 'roadtrip.avoid.toll': 'Mautstraßen', + 'roadtrip.avoid.motorway': 'Autobahnen', + 'roadtrip.avoid.ferry': 'Fähren', + 'roadtrip.avoid.hint': 'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.', + 'roadtrip.avoid.unavailable': 'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.', + 'roadtrip.avoid.badge': '{count} gemieden', + 'roadtrip.avoid.missed': '{classes} unvermeidbar', + 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', 'roadtrip.stop.none': 'Ein Ziel', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Übernachten', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Abfahrt', 'roadtrip.alt.noMotorway': 'Ohne Autobahn', 'roadtrip.alt.noToll': 'Ohne Maut', + 'roadtrip.alt.noFerry': 'Ohne Fähre', 'roadtrip.summary.partial': 'Der Rest der Strecke wird noch berechnet', }; export default roadtrip; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 6a5d6ba95..53bf6ba62 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)', 'settings.routingBase': 'Eigene Routing-Instanz', 'settings.routingBaseHint': 'Eine eigene OSRM-Instanz. Leer nutzt die öffentlichen Server, die etwa eine Anfrage pro Sekunde erlauben — für einen Tag genug, für einen Roadtrip knapp. Wirkt erst nach einem Serverneustart.', + 'settings.valhallaBase': 'Eigene Valhalla-Instanz', + 'settings.valhallaBaseHint': 'Eine eigene Valhalla-Instanz. Sie wird gefragt, wenn eine Etappe Maut, Autobahn oder Fähre meiden soll — die öffentlichen OSRM-Server können das nicht. Leer nutzt die öffentliche FOSSGIS-Valhalla, außer oben steht schon eine eigene Routing-Instanz; dann wird gar keine zweite Maschine gefragt. Wirkt erst nach einem Serverneustart.', 'settings.mapHint': 'URL-Template für die Kartenkacheln', 'settings.mapProvider': 'Kartenanbieter', 'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 572426844..f98714b89 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Charging', 'roadtrip.stop.offRoad': '{distance} from the road', 'roadtrip.via.plugin': 'Stop on the way', - 'roadtrip.limit.title': 'Driving limits', + 'roadtrip.limit.title': 'Driving settings', 'roadtrip.limit.legLabel': 'Longest drive at once', 'roadtrip.limit.dayLabel': 'Driving per day', 'roadtrip.limit.rangeLabel': 'Range on one tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} over', 'roadtrip.limit.range': '{distance} since the last fill-up', 'roadtrip.limit.none': 'None set', + 'roadtrip.avoid.section': 'Avoid where possible', + 'roadtrip.avoid.toll': 'Toll roads', + 'roadtrip.avoid.motorway': 'Motorways', + 'roadtrip.avoid.ferry': 'Ferries', + 'roadtrip.avoid.hint': 'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.', + 'roadtrip.avoid.unavailable': 'Not available: this instance routes through its own engine, which cannot leave a road class out.', + 'roadtrip.avoid.badge': '{count} avoided', + 'roadtrip.avoid.missed': '{classes} unavoidable', + 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', 'roadtrip.stop.none': 'A destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Overnight', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Leave', 'roadtrip.alt.noMotorway': 'No motorway', 'roadtrip.alt.noToll': 'No tolls', + 'roadtrip.alt.noFerry': 'No ferry', 'roadtrip.summary.partial': 'Still working out the rest of the drive', }; export default roadtrip; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 595a57588..b83ce71c4 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -35,6 +35,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)', 'settings.routingBase': 'Own routing engine', 'settings.routingBaseHint': 'An OSRM instance of your own. Empty uses the public servers, which allow about one request a second — enough for a day, tight for a road trip. Takes effect after a server restart.', + 'settings.valhallaBase': 'Own Valhalla instance', + 'settings.valhallaBaseHint': 'A Valhalla of your own. It is asked when a leg should avoid tolls, motorways or ferries — the public OSRM servers cannot answer that. Empty uses the public FOSSGIS Valhalla, unless an own routing engine is named above, in which case no second engine is asked at all. Takes effect after a server restart.', 'settings.mapHint': 'URL template for map tiles', 'settings.mapProvider': 'Map Provider', 'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index f19d07bef..14f32415b 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recarga', 'roadtrip.stop.offRoad': '{distance} desde la carretera', 'roadtrip.via.plugin': 'Parada en el camino', - 'roadtrip.limit.title': 'Límites de conducción', + 'roadtrip.limit.title': 'Ajustes de conducción', 'roadtrip.limit.legLabel': 'Trayecto más largo seguido', 'roadtrip.limit.dayLabel': 'Conducción por día', 'roadtrip.limit.rangeLabel': 'Autonomía por depósito', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de más', 'roadtrip.limit.range': '{distance} desde el último repostaje', 'roadtrip.limit.none': 'Ninguno definido', + 'roadtrip.avoid.section': 'Evitar si es posible', + 'roadtrip.avoid.toll': 'Peajes', + 'roadtrip.avoid.motorway': 'Autopistas', + 'roadtrip.avoid.ferry': 'Ferris', + 'roadtrip.avoid.hint': 'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.', + 'roadtrip.avoid.unavailable': 'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.', + 'roadtrip.avoid.badge': '{count} evitados', + 'roadtrip.avoid.missed': '{classes} inevitable(s)', + 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', 'roadtrip.stop.none': 'Un destino', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Salida', 'roadtrip.alt.noMotorway': 'Sin autopista', 'roadtrip.alt.noToll': 'Sin peajes', + 'roadtrip.alt.noFerry': 'Sin ferri', 'roadtrip.summary.partial': 'Aún se está calculando el resto del trayecto', }; export default roadtrip; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index f8e18ba01..bd918be0f 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)', 'settings.routingBase': 'Motor de rutas propio', 'settings.routingBaseHint': 'Una instancia propia de OSRM. Vacío usa los servidores públicos, que permiten cerca de una petición por segundo: suficiente para un día, justo para una ruta larga. Surte efecto tras reiniciar el servidor.', + 'settings.valhallaBase': 'Instancia de Valhalla propia', + 'settings.valhallaBaseHint': 'Una instancia de Valhalla propia. Se consulta cuando un tramo debe evitar peajes, autopistas o ferris: los servidores OSRM públicos no pueden responder a eso. Vacío usa el Valhalla público de FOSSGIS, salvo que arriba se indique un motor de rutas propio, en cuyo caso no se consulta ningún segundo motor. Surte efecto tras reiniciar el servidor.', 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', 'settings.mapProvider': 'Proveedor de mapa', 'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index f757d68cf..245059321 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recharge', 'roadtrip.stop.offRoad': '{distance} de la route', 'roadtrip.via.plugin': 'Arrêt en chemin', - 'roadtrip.limit.title': 'Limites de conduite', + 'roadtrip.limit.title': 'Réglages de conduite', 'roadtrip.limit.legLabel': 'Plus long trajet d’affilée', 'roadtrip.limit.dayLabel': 'Conduite par jour', 'roadtrip.limit.rangeLabel': 'Autonomie par plein', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de trop', 'roadtrip.limit.range': '{distance} depuis le dernier plein', 'roadtrip.limit.none': 'Aucune définie', + 'roadtrip.avoid.section': 'Éviter si possible', + 'roadtrip.avoid.toll': 'Routes à péage', + 'roadtrip.avoid.motorway': 'Autoroutes', + 'roadtrip.avoid.ferry': 'Ferries', + 'roadtrip.avoid.hint': 'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.', + 'roadtrip.avoid.unavailable': 'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.', + 'roadtrip.avoid.badge': '{count} évité(s)', + 'roadtrip.avoid.missed': '{classes} inévitable(s)', + 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', 'roadtrip.stop.none': 'Une destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Nuitée', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Depart', 'roadtrip.alt.noMotorway': 'Sans autoroute', 'roadtrip.alt.noToll': 'Sans peage', + 'roadtrip.alt.noFerry': 'Sans ferry', 'roadtrip.summary.partial': 'Le reste du trajet est encore en cours de calcul', }; export default roadtrip; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 6b92c5af5..f71265138 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)', 'settings.routingBase': 'Moteur de calcul d’itinéraire propre', 'settings.routingBaseHint': 'Votre propre instance OSRM. Vide utilise les serveurs publics, limités à environ une requête par seconde : suffisant pour une journée, juste pour un road trip. Prend effet après un redémarrage du serveur.', + 'settings.valhallaBase': 'Instance Valhalla propre', + 'settings.valhallaBaseHint': 'Votre propre instance Valhalla. Elle est interrogée quand une étape doit éviter les péages, les autoroutes ou les ferries — les serveurs OSRM publics ne savent pas répondre à cela. Vide utilise le Valhalla public de FOSSGIS, sauf si un moteur de calcul d’itinéraire propre est indiqué ci-dessus, auquel cas aucun second moteur n’est interrogé. Prend effet après un redémarrage du serveur.', 'settings.mapHint': "Modèle d'URL pour les tuiles de carte", 'settings.mapProvider': 'Fournisseur de carte', 'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index ceeb4c90c..369c33372 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Φόρτιση', 'roadtrip.stop.offRoad': '{distance} από τον δρόμο', 'roadtrip.via.plugin': 'Στάση στον δρόμο', - 'roadtrip.limit.title': 'Όρια οδήγησης', + 'roadtrip.limit.title': 'Ρυθμίσεις οδήγησης', 'roadtrip.limit.legLabel': 'Μεγαλύτερη συνεχής οδήγηση', 'roadtrip.limit.dayLabel': 'Οδήγηση ανά ημέρα', 'roadtrip.limit.rangeLabel': 'Αυτονομία με ένα γέμισμα', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} πάνω', 'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό', 'roadtrip.limit.none': 'Δεν έχουν οριστεί', + 'roadtrip.avoid.section': 'Αποφυγή όπου γίνεται', + 'roadtrip.avoid.toll': 'Δρόμοι με διόδια', + 'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι', + 'roadtrip.avoid.ferry': 'Φέρι μποτ', + 'roadtrip.avoid.hint': 'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.', + 'roadtrip.avoid.unavailable': 'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.', + 'roadtrip.avoid.badge': '{count} αποφεύγονται', + 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', + 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Αναχωρηση', 'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο', 'roadtrip.alt.noToll': 'Χωρις διοδια', + 'roadtrip.alt.noFerry': 'Χωρίς φέρι', 'roadtrip.summary.partial': 'Υπολογίζεται ακόμη η υπόλοιπη διαδρομή', }; export default roadtrip; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 9793fd420..56b156ea7 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Αφήστε κενό για OpenStreetMap (προεπιλογή)', 'settings.routingBase': 'Δική σας μηχανή δρομολόγησης', 'settings.routingBaseHint': 'Δική σας εγκατάσταση OSRM. Κενό σημαίνει τους δημόσιους διακομιστές, που επιτρέπουν περίπου ένα αίτημα ανά δευτερόλεπτο: αρκεί για μία ημέρα, λίγο για ένα οδικό ταξίδι. Ισχύει μετά από επανεκκίνηση του διακομιστή.', + 'settings.valhallaBase': 'Δική σας υπηρεσία Valhalla', + 'settings.valhallaBaseHint': 'Δική σας υπηρεσία Valhalla. Ρωτιέται όταν ένα σκέλος πρέπει να αποφύγει διόδια, αυτοκινητόδρομους ή φέρι — οι δημόσιοι διακομιστές OSRM δεν μπορούν να απαντήσουν σε αυτό. Κενό χρησιμοποιεί το δημόσιο Valhalla του FOSSGIS, εκτός αν παραπάνω δηλώνεται δική σας μηχανή δρομολόγησης, οπότε δεν ρωτιέται καμία δεύτερη μηχανή. Ισχύει μετά από επανεκκίνηση του διακομιστή.', 'settings.mapHint': 'Πρότυπο URL για πλακίδια χάρτη', 'settings.mapProvider': 'Πάροχος Χάρτη', 'settings.mapProviderHint': diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 311c0f6a8..81cc19e3c 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Töltés', 'roadtrip.stop.offRoad': '{distance} az úttól', 'roadtrip.via.plugin': 'Megálló útközben', - 'roadtrip.limit.title': 'Vezetési korlátok', + 'roadtrip.limit.title': 'Vezetési beállítások', 'roadtrip.limit.legLabel': 'Leghosszabb egyhuzamban vezetés', 'roadtrip.limit.dayLabel': 'Vezetés naponta', 'roadtrip.limit.rangeLabel': 'Hatótáv egy tankkal', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} túl', 'roadtrip.limit.range': '{distance} az utolsó tankolás óta', 'roadtrip.limit.none': 'Nincs beállítva', + 'roadtrip.avoid.section': 'Kerülés, ha lehet', + 'roadtrip.avoid.toll': 'Fizetős utak', + 'roadtrip.avoid.motorway': 'Autópályák', + 'roadtrip.avoid.ferry': 'Kompok', + 'roadtrip.avoid.hint': 'Ez súlyozás, nem tiltás. Ha egy útnak nincs kerülője, mégis használja, és a nap jelzi.', + 'roadtrip.avoid.unavailable': 'Nem érhető el: ez a példány saját motort használ, amely nem tud kihagyni útosztályt.', + 'roadtrip.avoid.badge': '{count} kerülve', + 'roadtrip.avoid.missed': '{classes} nem kerülhető', + 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', 'roadtrip.stop.none': 'Úti cél', 'roadtrip.stay.mode.pause': 'Szünet', 'roadtrip.stay.mode.night': 'Éjszakázás', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Indulas', 'roadtrip.alt.noMotorway': 'Autopalya nelkul', 'roadtrip.alt.noToll': 'Dijmentes', + 'roadtrip.alt.noFerry': 'Komp nélkül', 'roadtrip.summary.partial': 'A hátralévő útszakasz számítása még folyamatban', }; export default roadtrip; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 548b066ca..70b7937b0 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)', 'settings.routingBase': 'Saját útvonaltervező', 'settings.routingBaseHint': 'Saját OSRM-példány. Üresen hagyva a nyilvános kiszolgálókat használja, amelyek másodpercenként körülbelül egy kérést engednek — egy napra elég, egy autós útra kevés. A kiszolgáló újraindítása után lép életbe.', + 'settings.valhallaBase': 'Saját Valhalla-példány', + 'settings.valhallaBaseHint': 'Saját Valhalla-példány. Akkor kérdezzük meg, ha egy szakasznak kerülnie kell a fizetős utakat, az autópályákat vagy a kompokat — a nyilvános OSRM-kiszolgálók erre nem tudnak válaszolni. Üresen a FOSSGIS nyilvános Valhalláját használja, kivéve ha fent saját útvonaltervező szerepel; akkor egyáltalán nem kérdezünk második motort. A szerver újraindítása után lép életbe.', 'settings.mapHint': 'URL sablon a térképcsempékhez', 'settings.mapProvider': 'Térkép szolgáltató', 'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 945da4563..9b36fafab 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Pengisian daya', 'roadtrip.stop.offRoad': '{distance} dari jalan', 'roadtrip.via.plugin': 'Berhenti di jalan', - 'roadtrip.limit.title': 'Batas mengemudi', + 'roadtrip.limit.title': 'Pengaturan berkendara', 'roadtrip.limit.legLabel': 'Perjalanan terlama sekali jalan', 'roadtrip.limit.dayLabel': 'Mengemudi per hari', 'roadtrip.limit.rangeLabel': 'Jarak per tangki', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'lebih {time}', 'roadtrip.limit.range': '{distance} sejak pengisian terakhir', 'roadtrip.limit.none': 'Belum diatur', + 'roadtrip.avoid.section': 'Hindari bila bisa', + 'roadtrip.avoid.toll': 'Jalan tol', + 'roadtrip.avoid.motorway': 'Jalan bebas hambatan', + 'roadtrip.avoid.ferry': 'Feri', + 'roadtrip.avoid.hint': 'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.', + 'roadtrip.avoid.unavailable': 'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.', + 'roadtrip.avoid.badge': '{count} dihindari', + 'roadtrip.avoid.missed': '{classes} tak terhindarkan', + 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', 'roadtrip.stop.none': 'Sebuah tujuan', 'roadtrip.stay.mode.pause': 'Istirahat', 'roadtrip.stay.mode.night': 'Menginap', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Berangkat', 'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat', 'roadtrip.alt.noToll': 'Tanpa tol', + 'roadtrip.alt.noFerry': 'Tanpa feri', 'roadtrip.summary.partial': 'Sisa perjalanan masih dihitung', }; export default roadtrip; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 8f8d0a126..5d1914075 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)', 'settings.routingBase': 'Mesin rute sendiri', 'settings.routingBaseHint': 'Instans OSRM milik sendiri. Kosong berarti server publik, yang mengizinkan sekitar satu permintaan per detik — cukup untuk sehari, mepet untuk perjalanan darat. Berlaku setelah server dimulai ulang.', + 'settings.valhallaBase': 'Instans Valhalla sendiri', + 'settings.valhallaBaseHint': 'Instans Valhalla sendiri. Ia ditanya ketika satu ruas harus menghindari tol, jalan bebas hambatan, atau feri — server OSRM publik tidak bisa menjawabnya. Kosong memakai Valhalla publik dari FOSSGIS, kecuali di atas sudah diisi mesin rute sendiri; maka tidak ada mesin kedua yang ditanya. Berlaku setelah server dijalankan ulang.', 'settings.mapHint': 'Template URL untuk tile peta', 'settings.mapProvider': 'Penyedia peta', 'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index c21e82999..9a9127bb4 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ricarica', 'roadtrip.stop.offRoad': '{distance} dalla strada', 'roadtrip.via.plugin': 'Sosta lungo il percorso', - 'roadtrip.limit.title': 'Limiti di guida', + 'roadtrip.limit.title': 'Impostazioni di guida', 'roadtrip.limit.legLabel': 'Tratto più lungo di fila', 'roadtrip.limit.dayLabel': 'Guida al giorno', 'roadtrip.limit.rangeLabel': 'Autonomia con un pieno', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} in più', 'roadtrip.limit.range': '{distance} dall’ultimo rifornimento', 'roadtrip.limit.none': 'Nessuno impostato', + 'roadtrip.avoid.section': 'Evita se possibile', + 'roadtrip.avoid.toll': 'Strade a pedaggio', + 'roadtrip.avoid.motorway': 'Autostrade', + 'roadtrip.avoid.ferry': 'Traghetti', + 'roadtrip.avoid.hint': 'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.', + 'roadtrip.avoid.unavailable': 'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.', + 'roadtrip.avoid.badge': '{count} evitati', + 'roadtrip.avoid.missed': '{classes} inevitabili', + 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', 'roadtrip.stop.none': 'Una destinazione', 'roadtrip.stay.mode.pause': 'Sosta', 'roadtrip.stay.mode.night': 'Pernottamento', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Partenza', 'roadtrip.alt.noMotorway': 'Senza autostrada', 'roadtrip.alt.noToll': 'Senza pedaggi', + 'roadtrip.alt.noFerry': 'Senza traghetto', 'roadtrip.summary.partial': 'Il resto del percorso è ancora in calcolo', }; export default roadtrip; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index f98bcc50c..d60e09895 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)', 'settings.routingBase': 'Motore di percorso proprio', 'settings.routingBaseHint': 'Una tua istanza OSRM. Vuoto usa i server pubblici, che consentono circa una richiesta al secondo: sufficiente per un giorno, poco per un viaggio in auto. Ha effetto dopo il riavvio del server.', + 'settings.valhallaBase': 'Istanza Valhalla propria', + 'settings.valhallaBaseHint': 'Una istanza Valhalla propria. Viene interrogata quando una tappa deve evitare pedaggi, autostrade o traghetti: i server OSRM pubblici non sanno rispondere. Vuoto usa il Valhalla pubblico di FOSSGIS, a meno che sopra non sia indicato un motore di routing proprio, nel qual caso non viene interrogato alcun secondo motore. Ha effetto dopo il riavvio del server.', 'settings.mapHint': 'Modello URL per i tile della mappa', 'settings.mapProvider': 'Provider mappa', 'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index d490eacb6..8de6f1eb1 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '道路から{distance}', 'roadtrip.via.plugin': '途中の立ち寄り', - 'roadtrip.limit.title': '運転の上限', + 'roadtrip.limit.title': '運転の設定', 'roadtrip.limit.legLabel': '連続運転の上限', 'roadtrip.limit.dayLabel': '1日の運転', 'roadtrip.limit.rangeLabel': '満タンでの航続', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time}超過', 'roadtrip.limit.range': '前回の給油から{distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.avoid.section': 'できるだけ避ける', + 'roadtrip.avoid.toll': '有料道路', + 'roadtrip.avoid.motorway': '高速道路', + 'roadtrip.avoid.ferry': 'フェリー', + 'roadtrip.avoid.hint': '禁止ではなく、優先度を下げるだけです。迂回できない場合はそのまま通り、その日に表示されます。', + 'roadtrip.avoid.unavailable': '利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。', + 'roadtrip.avoid.badge': '{count} 件を回避', + 'roadtrip.avoid.missed': '{classes} は回避できず', + 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出発', 'roadtrip.alt.noMotorway': '高速道路なし', 'roadtrip.alt.noToll': '有料道路なし', + 'roadtrip.alt.noFerry': 'フェリーなし', 'roadtrip.summary.partial': '残りの行程を計算しています', }; export default roadtrip; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index d8d58f8f1..758d9c1f5 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用', 'settings.routingBase': '自前のルーティングサーバー', 'settings.routingBaseHint': '自前の OSRM インスタンス。空欄なら公開サーバーを使いますが、毎秒 1 リクエスト程度の制限があり、1 日分には足りてもロードトリップには足りません。サーバー再起動後に反映されます。', + 'settings.valhallaBase': '自前の Valhalla インスタンス', + 'settings.valhallaBaseHint': '自前の Valhalla インスタンス。有料道路・高速道路・フェリーを避けたい区間で問い合わせます。公開の OSRM サーバーはこれに答えられません。空欄なら FOSSGIS の公開 Valhalla を使いますが、上で自前のルーティングサーバーを指定している場合は二つ目のエンジンを一切問い合わせません。サーバー再起動後に反映されます。', 'settings.mapHint': '地図タイルのURLテンプレート', 'settings.mapProvider': '地図プロバイダー', 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 133f792db..d05b81de7 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '충전', 'roadtrip.stop.offRoad': '도로에서 {distance}', 'roadtrip.via.plugin': '경유 정차', - 'roadtrip.limit.title': '운전 한도', + 'roadtrip.limit.title': '주행 설정', 'roadtrip.limit.legLabel': '연속 운전 최대', 'roadtrip.limit.dayLabel': '하루 운전', 'roadtrip.limit.rangeLabel': '1회 주유 주행거리', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} 초과', 'roadtrip.limit.range': '마지막 주유 이후 {distance}', 'roadtrip.limit.none': '설정 안 됨', + 'roadtrip.avoid.section': '가능하면 피하기', + 'roadtrip.avoid.toll': '유료 도로', + 'roadtrip.avoid.motorway': '고속도로', + 'roadtrip.avoid.ferry': '페리', + 'roadtrip.avoid.hint': '금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.', + 'roadtrip.avoid.unavailable': '사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.', + 'roadtrip.avoid.badge': '{count}개 회피', + 'roadtrip.avoid.missed': '{classes} 회피 불가', + 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '출발', 'roadtrip.alt.noMotorway': '고속도로 제외', 'roadtrip.alt.noToll': '통행료 없음', + 'roadtrip.alt.noFerry': '페리 없음', 'roadtrip.summary.partial': '남은 구간을 계산하고 있습니다', }; export default roadtrip; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index fed072ccf..3c9e8ca0c 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '비워두면 OpenStreetMap (기본값) 사용', 'settings.routingBase': '자체 경로 서버', 'settings.routingBaseHint': '자체 OSRM 인스턴스입니다. 비워 두면 초당 약 한 번의 요청만 허용하는 공개 서버를 사용합니다. 하루치는 충분하지만 로드트립에는 빠듯합니다. 서버를 다시 시작해야 적용됩니다.', + 'settings.valhallaBase': '자체 Valhalla 인스턴스', + 'settings.valhallaBaseHint': '자체 Valhalla 인스턴스. 구간에서 통행료, 고속도로, 페리를 피해야 할 때 이 서버에 묻습니다. 공개 OSRM 서버는 이에 답하지 못합니다. 비워 두면 FOSSGIS의 공개 Valhalla를 쓰지만, 위에 자체 라우팅 서버를 적어 두었다면 두 번째 엔진은 아예 묻지 않습니다. 서버를 다시 시작한 뒤에 적용됩니다.', 'settings.mapHint': '지도 타일 URL 템플릿', 'settings.mapProvider': '지도 공급자', 'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 2f21a2bb5..6219aac38 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} vanaf de weg', 'roadtrip.via.plugin': 'Stop onderweg', - 'roadtrip.limit.title': 'Rijlimieten', + 'roadtrip.limit.title': 'Rij-instellingen', 'roadtrip.limit.legLabel': 'Langste rit achtereen', 'roadtrip.limit.dayLabel': 'Rijden per dag', 'roadtrip.limit.rangeLabel': 'Bereik per tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} te veel', 'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt', 'roadtrip.limit.none': 'Geen ingesteld', + 'roadtrip.avoid.section': 'Vermijden waar het kan', + 'roadtrip.avoid.toll': 'Tolwegen', + 'roadtrip.avoid.motorway': 'Snelwegen', + 'roadtrip.avoid.ferry': 'Veerboten', + 'roadtrip.avoid.hint': 'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.', + 'roadtrip.avoid.unavailable': 'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.', + 'roadtrip.avoid.badge': '{count} vermeden', + 'roadtrip.avoid.missed': '{classes} onvermijdelijk', + 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', 'roadtrip.stop.none': 'Een bestemming', 'roadtrip.stay.mode.pause': 'Pauze', 'roadtrip.stay.mode.night': 'Overnachten', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Vertrek', 'roadtrip.alt.noMotorway': 'Zonder snelweg', 'roadtrip.alt.noToll': 'Zonder tol', + 'roadtrip.alt.noFerry': 'Zonder veerboot', 'roadtrip.summary.partial': 'De rest van de rit wordt nog berekend', }; export default roadtrip; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 52a2a4bc5..afe8ab0d5 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)', 'settings.routingBase': 'Eigen routeserver', 'settings.routingBaseHint': 'Een eigen OSRM-server. Leeg gebruikt de publieke servers, die ongeveer één verzoek per seconde toestaan — genoeg voor een dag, krap voor een roadtrip. Werkt na een herstart van de server.', + 'settings.valhallaBase': 'Eigen Valhalla-instantie', + 'settings.valhallaBaseHint': 'Een eigen Valhalla-instantie. Die wordt geraadpleegd wanneer een traject tol, snelwegen of veerboten moet vermijden — dat kunnen de publieke OSRM-servers niet beantwoorden. Leeg gebruikt de publieke FOSSGIS-Valhalla, tenzij hierboven een eigen routeringsmotor staat; dan wordt er helemaal geen tweede motor geraadpleegd. Werkt pas na een herstart van de server.', 'settings.mapHint': 'URL-sjabloon voor kaarttegels', 'settings.mapProvider': 'Kaartprovider', 'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 1a47cd41e..9bba0abfe 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ładowanie', 'roadtrip.stop.offRoad': '{distance} od drogi', 'roadtrip.via.plugin': 'Postojój po drodze', - 'roadtrip.limit.title': 'Limity jazdy', + 'roadtrip.limit.title': 'Ustawienia jazdy', 'roadtrip.limit.legLabel': 'Najdłuższa jazda bez przerwy', 'roadtrip.limit.dayLabel': 'Jazda dziennie', 'roadtrip.limit.rangeLabel': 'Zasięg na jednym baku', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} ponad', 'roadtrip.limit.range': '{distance} od ostatniego tankowania', 'roadtrip.limit.none': 'Brak ustawionych', + 'roadtrip.avoid.section': 'Omijaj, jeśli się da', + 'roadtrip.avoid.toll': 'Drogi płatne', + 'roadtrip.avoid.motorway': 'Autostrady', + 'roadtrip.avoid.ferry': 'Promy', + 'roadtrip.avoid.hint': 'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.', + 'roadtrip.avoid.unavailable': 'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.', + 'roadtrip.avoid.badge': '{count} omijane', + 'roadtrip.avoid.missed': '{classes} nie do ominięcia', + 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', 'roadtrip.stop.none': 'Cel podróży', 'roadtrip.stay.mode.pause': 'Postój', 'roadtrip.stay.mode.night': 'Nocleg', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Odjazd', 'roadtrip.alt.noMotorway': 'Bez autostrady', 'roadtrip.alt.noToll': 'Bez oplat', + 'roadtrip.alt.noFerry': 'Bez promu', 'roadtrip.summary.partial': 'Reszta trasy jest jeszcze obliczana', }; export default roadtrip; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 7223883e1..2e65005ad 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)', 'settings.routingBase': 'Własny silnik tras', 'settings.routingBaseHint': 'Własna instancja OSRM. Puste używa serwerów publicznych, które pozwalają na około jedno zapytanie na sekundę — wystarczy na dzień, mało na road trip. Działa po restarcie serwera.', + 'settings.valhallaBase': 'Własna instancja Valhalli', + 'settings.valhallaBaseHint': 'Własna instancja Valhalli. Pytana wtedy, gdy odcinek ma omijać opłaty, autostrady lub promy — publiczne serwery OSRM nie potrafią na to odpowiedzieć. Puste używa publicznej Valhalli FOSSGIS, chyba że powyżej wskazano własny silnik tras; wtedy żaden drugi silnik nie jest pytany. Działa po ponownym uruchomieniu serwera.', 'settings.mapHint': 'Szablon URL dla kafelków mapy', 'settings.mapProvider': 'Dostawca mapy', 'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 488e7827b..194aa6885 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} от дороги', 'roadtrip.via.plugin': 'Остановка в пути', - 'roadtrip.limit.title': 'Пределы вождения', + 'roadtrip.limit.title': 'Настройки вождения', 'roadtrip.limit.legLabel': 'Самый долгий перегон', 'roadtrip.limit.dayLabel': 'Вождение за день', 'roadtrip.limit.rangeLabel': 'Запас хода на баке', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'на {time} больше', 'roadtrip.limit.range': '{distance} с последней заправки', 'roadtrip.limit.none': 'Не заданы', + 'roadtrip.avoid.section': 'Избегать по возможности', + 'roadtrip.avoid.toll': 'Платные дороги', + 'roadtrip.avoid.motorway': 'Автомагистрали', + 'roadtrip.avoid.ferry': 'Паромы', + 'roadtrip.avoid.hint': 'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.', + 'roadtrip.avoid.unavailable': 'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.', + 'roadtrip.avoid.badge': '{count} в обход', + 'roadtrip.avoid.missed': '{classes} не обойти', + 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Отправление', 'roadtrip.alt.noMotorway': 'Без автомагистрали', 'roadtrip.alt.noToll': 'Без платных дорог', + 'roadtrip.alt.noFerry': 'Без парома', 'roadtrip.summary.partial': 'Остальная часть маршрута ещё рассчитывается', }; export default roadtrip; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 2ad5c54b9..70c8c0dcc 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)', 'settings.routingBase': 'Собственный сервер маршрутов', 'settings.routingBaseHint': 'Собственный экземпляр OSRM. Пусто — используются публичные серверы с ограничением около одного запроса в секунду: на день хватает, для автопутешествия мало. Вступает в силу после перезапуска сервера.', + 'settings.valhallaBase': 'Свой сервер Valhalla', + 'settings.valhallaBaseHint': 'Свой сервер Valhalla. К нему обращаются, когда участок должен обойти платные дороги, автомагистрали или паромы — публичные серверы OSRM на это ответить не могут. Пусто использует публичный Valhalla от FOSSGIS, если выше не указан свой маршрутизатор; тогда второй движок не запрашивается вовсе. Действует после перезапуска сервера.', 'settings.mapHint': 'URL-шаблон для тайлов карты', 'settings.mapProvider': 'Провайдер карты', 'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index efc90368f..f466fe0d6 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laddning', 'roadtrip.stop.offRoad': '{distance} från vägen', 'roadtrip.via.plugin': 'Stopp på vägen', - 'roadtrip.limit.title': 'Körgränser', + 'roadtrip.limit.title': 'Körinställningar', 'roadtrip.limit.legLabel': 'Längsta körning i sträck', 'roadtrip.limit.dayLabel': 'Körning per dag', 'roadtrip.limit.rangeLabel': 'Räckvidd per tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} över', 'roadtrip.limit.range': '{distance} sedan senaste tankningen', 'roadtrip.limit.none': 'Inga angivna', + 'roadtrip.avoid.section': 'Undvik om det går', + 'roadtrip.avoid.toll': 'Avgiftsvägar', + 'roadtrip.avoid.motorway': 'Motorvägar', + 'roadtrip.avoid.ferry': 'Färjor', + 'roadtrip.avoid.hint': 'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.', + 'roadtrip.avoid.unavailable': 'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.', + 'roadtrip.avoid.badge': '{count} undviks', + 'roadtrip.avoid.missed': '{classes} går inte att undvika', + 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', 'roadtrip.stop.none': 'Ett resmål', 'roadtrip.stay.mode.pause': 'Paus', 'roadtrip.stay.mode.night': 'Övernattning', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Avfard', 'roadtrip.alt.noMotorway': 'Utan motorvag', 'roadtrip.alt.noToll': 'Utan vagavgifter', + 'roadtrip.alt.noFerry': 'Utan färja', 'roadtrip.summary.partial': 'Resten av resan beräknas fortfarande', }; export default roadtrip; diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index a75e7e055..0c5e07858 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Lämna fältet tomt för OpenStreetMap (standard)', 'settings.routingBase': 'Egen ruttmotor', 'settings.routingBaseHint': 'En egen OSRM-instans. Tomt använder de publika servrarna, som tillåter ungefär en förfrågan per sekund – nog för en dag, knappt för en bilresa. Träder i kraft efter omstart av servern.', + 'settings.valhallaBase': 'Egen Valhalla-instans', + 'settings.valhallaBaseHint': 'En egen Valhalla-instans. Den frågas när en etapp ska undvika vägavgifter, motorvägar eller färjor — de publika OSRM-servrarna kan inte svara på det. Tomt använder FOSSGIS publika Valhalla, om inte en egen ruttmotor angetts ovan; då frågas ingen andra motor alls. Börjar gälla efter en omstart av servern.', 'settings.mapHint': 'URL-mall för kartrutor', 'settings.mapProvider': 'Kartleverantör', 'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 5fc67b61e..04eecbfd1 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Şarj', 'roadtrip.stop.offRoad': 'yoldan {distance}', 'roadtrip.via.plugin': 'Yol üzerinde mola', - 'roadtrip.limit.title': 'Sürüş sınırları', + 'roadtrip.limit.title': 'Sürüş ayarları', 'roadtrip.limit.legLabel': 'Aralıksız en uzun sürüş', 'roadtrip.limit.dayLabel': 'Günlük sürüş', 'roadtrip.limit.rangeLabel': 'Bir depoyla menzil', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} fazla', 'roadtrip.limit.range': 'son yakıt alımından beri {distance}', 'roadtrip.limit.none': 'Ayarlanmadı', + 'roadtrip.avoid.section': 'Mümkünse kaçın', + 'roadtrip.avoid.toll': 'Paralı yollar', + 'roadtrip.avoid.motorway': 'Otoyollar', + 'roadtrip.avoid.ferry': 'Feribotlar', + 'roadtrip.avoid.hint': 'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.', + 'roadtrip.avoid.unavailable': 'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.', + 'roadtrip.avoid.badge': '{count} kaçınılıyor', + 'roadtrip.avoid.missed': '{classes} kaçınılmaz', + 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', 'roadtrip.stop.none': 'Bir varış noktası', 'roadtrip.stay.mode.pause': 'Mola', 'roadtrip.stay.mode.night': 'Konaklama', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Kalkis', 'roadtrip.alt.noMotorway': 'Otoyolsuz', 'roadtrip.alt.noToll': 'Ucretsiz', + 'roadtrip.alt.noFerry': 'Feribotsuz', 'roadtrip.summary.partial': 'Yolun geri kalanı hâlâ hesaplanıyor', }; export default roadtrip; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 3916ef8ae..ad9da7301 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'OpenStreetMap için boş bırakın (varsayılan)', 'settings.routingBase': 'Kendi rota motoru', 'settings.routingBaseHint': 'Kendi OSRM sunucunuz. Boş bırakılırsa saniyede yaklaşık bir istek veren genel sunucular kullanılır: bir gün için yeter, uzun yolculuk için az. Sunucu yeniden başlatıldığında etkin olur.', + 'settings.valhallaBase': 'Kendi Valhalla sunucunuz', + 'settings.valhallaBaseHint': 'Kendi Valhalla sunucunuz. Bir etabın gişeleri, otoyolları veya feribotları es geçmesi gerektiğinde ona sorulur — açık OSRM sunucuları bunu yanıtlayamaz. Boş bırakılırsa FOSSGIS’in açık Valhalla’sı kullanılır; yukarıda kendi rota motorunuz yazılıysa ikinci bir motor hiç sorulmaz. Sunucu yeniden başlatıldıktan sonra geçerli olur.', 'settings.mapHint': 'Harita kutucukları için URL şablonu', 'settings.mapProvider': 'Harita Sağlayıcısı', 'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 870a9033d..23c3ae7d0 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} від дороги', 'roadtrip.via.plugin': 'Зупинка в дорозі', - 'roadtrip.limit.title': 'Межі водіння', + 'roadtrip.limit.title': 'Налаштування водіння', 'roadtrip.limit.legLabel': 'Найдовший перегін', 'roadtrip.limit.dayLabel': 'Водіння за день', 'roadtrip.limit.rangeLabel': 'Запас ходу на баку', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'на {time} більше', 'roadtrip.limit.range': '{distance} від останньої заправки', 'roadtrip.limit.none': 'Не задано', + 'roadtrip.avoid.section': 'Уникати за можливості', + 'roadtrip.avoid.toll': 'Платні дороги', + 'roadtrip.avoid.motorway': 'Автомагістралі', + 'roadtrip.avoid.ferry': 'Пороми', + 'roadtrip.avoid.hint': 'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.', + 'roadtrip.avoid.unavailable': 'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.', + 'roadtrip.avoid.badge': '{count} в обхід', + 'roadtrip.avoid.missed': '{classes} не обійти', + 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Відʼїзд', 'roadtrip.alt.noMotorway': 'Без автомагістралі', 'roadtrip.alt.noToll': 'Без платних доріг', + 'roadtrip.alt.noFerry': 'Без порома', 'roadtrip.summary.partial': 'Решта маршруту ще обчислюється', }; export default roadtrip; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 3e356c4a1..250fde61e 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Залиште порожнім для OpenStreetMap (за замовчуванням)', 'settings.routingBase': 'Власний сервер маршрутів', 'settings.routingBaseHint': 'Власний примірник OSRM. Порожньо — використовуються публічні сервери з обмеженням близько одного запиту на секунду: на день вистачає, для автоподорожі мало. Діє після перезапуску сервера.', + 'settings.valhallaBase': 'Власний сервер Valhalla', + 'settings.valhallaBaseHint': 'Власний сервер Valhalla. До нього звертаються, коли ділянка має обійти платні дороги, автомагістралі або пороми — публічні сервери OSRM на це відповісти не можуть. Порожнє використовує публічний Valhalla від FOSSGIS, якщо вище не вказано власний маршрутизатор; тоді другий рушій не запитують узагалі. Діє після перезапуску сервера.', 'settings.mapHint': 'URL-шаблон для тайлів карти', 'settings.mapProvider': 'Провайдер карти', 'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index e0c831235..5723bf4aa 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Sạc điện', 'roadtrip.stop.offRoad': '{distance} từ đường', 'roadtrip.via.plugin': 'Dừng chân trên đường', - 'roadtrip.limit.title': 'Giới hạn lái xe', + 'roadtrip.limit.title': 'Cài đặt lái xe', 'roadtrip.limit.legLabel': 'Chặng lái dài nhất', 'roadtrip.limit.dayLabel': 'Lái xe mỗi ngày', 'roadtrip.limit.rangeLabel': 'Quãng đường mỗi bình', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'vượt {time}', 'roadtrip.limit.range': '{distance} kể từ lần đổ nhiên liệu cuối', 'roadtrip.limit.none': 'Chưa đặt', + 'roadtrip.avoid.section': 'Tránh nếu có thể', + 'roadtrip.avoid.toll': 'Đường thu phí', + 'roadtrip.avoid.motorway': 'Đường cao tốc', + 'roadtrip.avoid.ferry': 'Phà', + 'roadtrip.avoid.hint': 'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.', + 'roadtrip.avoid.unavailable': 'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.', + 'roadtrip.avoid.badge': 'tránh {count}', + 'roadtrip.avoid.missed': '{classes} không tránh được', + 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', 'roadtrip.stop.none': 'Một điểm đến', 'roadtrip.stay.mode.pause': 'Nghỉ', 'roadtrip.stay.mode.night': 'Qua đêm', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Roi di', 'roadtrip.alt.noMotorway': 'Khong di cao toc', 'roadtrip.alt.noToll': 'Khong thu phi', + 'roadtrip.alt.noFerry': 'Không phà', 'roadtrip.summary.partial': 'Vẫn đang tính phần còn lại của chặng đường', }; export default roadtrip; diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 6bc035f3d..afa04b608 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Để trống cho OpenStreetMap (mặc định)', 'settings.routingBase': 'Máy chủ định tuyến riêng', 'settings.routingBaseHint': 'Máy chủ OSRM của riêng bạn. Để trống sẽ dùng máy chủ công cộng, cho phép khoảng một yêu cầu mỗi giây — đủ cho một ngày, chật cho chuyến đi dài. Có hiệu lực sau khi khởi động lại máy chủ.', + 'settings.valhallaBase': 'Máy chủ Valhalla riêng', + 'settings.valhallaBaseHint': 'Máy chủ Valhalla riêng. Nó được hỏi khi một chặng cần tránh trạm thu phí, đường cao tốc hoặc phà — các máy chủ OSRM công cộng không trả lời được việc đó. Để trống sẽ dùng Valhalla công cộng của FOSSGIS, trừ khi ở trên đã ghi một máy chủ định tuyến riêng; khi đó không hỏi máy chủ thứ hai nào cả. Có hiệu lực sau khi khởi động lại máy chủ.', 'settings.mapHint': 'Mẫu URL cho ô bản đồ', 'settings.mapProvider': 'Nhà cung cấp bản đồ', 'settings.mapProviderHint': diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index c2f3564ab..6f64cdf3a 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', - 'roadtrip.limit.title': '駕駛限制', + 'roadtrip.limit.title': '駕駛設定', 'roadtrip.limit.legLabel': '單次最長駕駛', 'roadtrip.limit.dayLabel': '每天駕駛', 'roadtrip.limit.rangeLabel': '單箱續航', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.avoid.section': '盡量避開', + 'roadtrip.avoid.toll': '收費公路', + 'roadtrip.avoid.motorway': '高速公路', + 'roadtrip.avoid.ferry': '渡輪', + 'roadtrip.avoid.hint': '這是降低權重,不是禁止。某段路程若無法繞開,仍會走,當天會說明。', + 'roadtrip.avoid.unavailable': '無法使用:此執行個體使用自建引擎,無法排除某類道路。', + 'roadtrip.avoid.badge': '避開 {count} 項', + 'roadtrip.avoid.missed': '無法避開{classes}', + 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出發', 'roadtrip.alt.noMotorway': '不走高速', 'roadtrip.alt.noToll': '免收費', + 'roadtrip.alt.noFerry': '不搭渡輪', 'roadtrip.summary.partial': '正在計算剩餘路程', }; export default roadtrip; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index ca9795168..71c4a204b 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)', 'settings.routingBase': '自建路徑規劃服務', 'settings.routingBaseHint': '你自己的 OSRM 實例。留空則使用公共伺服器,約每秒一個請求 — 單日夠用,長途自駕則吃緊。需重新啟動伺服器後生效。', + 'settings.valhallaBase': '自建 Valhalla 執行個體', + 'settings.valhallaBaseHint': '自建的 Valhalla 執行個體。當某段路程需要避開收費站、高速公路或渡輪時會向它詢問,公開的 OSRM 伺服器無法回答這類問題。留空則使用 FOSSGIS 的公開 Valhalla;若上方已填寫自建路徑規劃服務,則完全不詢問第二個引擎。重新啟動伺服器後生效。', 'settings.mapHint': '地圖瓦片 URL 模板', 'settings.mapProvider': '地圖提供商', 'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 8c34fb6cd..160f0db68 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充电', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', - 'roadtrip.limit.title': '驾驶限制', + 'roadtrip.limit.title': '驾驶设置', 'roadtrip.limit.legLabel': '单次最长驾驶', 'roadtrip.limit.dayLabel': '每天驾驶', 'roadtrip.limit.rangeLabel': '单箱续航', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未设置', + 'roadtrip.avoid.section': '尽量避开', + 'roadtrip.avoid.toll': '收费公路', + 'roadtrip.avoid.motorway': '高速公路', + 'roadtrip.avoid.ferry': '轮渡', + 'roadtrip.avoid.hint': '这是降低权重,不是禁止。某段路程若无法绕开,仍会走,当天会说明。', + 'roadtrip.avoid.unavailable': '不可用:此实例使用自建引擎,无法排除某类道路。', + 'roadtrip.avoid.badge': '避开 {count} 项', + 'roadtrip.avoid.missed': '无法避开{classes}', + 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出发', 'roadtrip.alt.noMotorway': '不走高速', 'roadtrip.alt.noToll': '免收费', + 'roadtrip.alt.noFerry': '不乘轮渡', 'roadtrip.summary.partial': '正在计算剩余路程', }; export default roadtrip; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index f1642bd84..4b1093e3b 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)', 'settings.routingBase': '自建路径规划服务', 'settings.routingBaseHint': '你自己的 OSRM 实例。留空则使用公共服务器,约每秒一个请求 — 单日够用,长途自驾则吃紧。需重启服务器后生效。', + 'settings.valhallaBase': '自建 Valhalla 实例', + 'settings.valhallaBaseHint': '自建的 Valhalla 实例。当某段路程需要避开收费站、高速公路或轮渡时会向它询问,公共 OSRM 服务器无法回答这类问题。留空则使用 FOSSGIS 的公共 Valhalla;若上方已填写自建路径规划服务,则完全不询问第二个引擎。重启服务器后生效。', 'settings.mapHint': '地图瓦片 URL 模板', 'settings.mapProvider': '地图提供商', 'settings.mapProviderHint': '影响行程规划器和旅程地图。足迹始终使用 Leaflet。', From 59b7c35e871b1e9ecdbc53dcaad44a61bf5e5494 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 18:25:23 +0200 Subject: [PATCH 14/29] fix(roadtrip): re-pin a via to the leg it was dragged onto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a via somewhere else on the map changed where the point IS and never where it sits in the chain. The anchor that decides which two stops it is threaded between was worked out once, when the point was dropped, and the drag sent only coordinates — `roadtripViaUpdateRequestSchema` carried a lat and a lng and nothing else. So a via pulled past the stop it used to precede kept claiming the earlier leg. The router then drove out to it and turned back before carrying on, which on screen is not a subtly wrong route: it is a spur that reads as the drag having done nothing at all, with the original road still sitting there unchanged. The anchor is not a property of the point but of where the point sits along the drive, so it is recomputed on a drag the same way it is on a drop. The projection that did that was inline in addRoadtripVia; it moves to a shared `anchorFor` and both callers use it. A drag differs from a drop in two ways, both deliberate. It measures against its OWN day: letting the nearest day win, which is right for a click on open map, would hand the via to a neighbouring day whose road happens to pass closer and make it disappear from the day it was dragged in. And it has no 2 km guard: pulling a via well off the current road is exactly what the gesture is for, and the anchor only says which leg gets bent. `after_order_index` is optional on the way in, so a drag inside one leg and an older client still send just the coordinates, and the stored pin is then left alone rather than cleared. --- .../components/Roadtrip/useRoadtripVias.ts | 13 +++-- .../useTripPlanner.roadtrip.test.tsx | 54 ++++++++++++++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 33 ++++++++++-- .../src/nest/roadtrip/roadtrip.controller.ts | 2 +- server/src/nest/roadtrip/roadtrip.service.ts | 24 ++++++++- .../unit/nest/roadtrip.controller.test.ts | 10 +++- .../tests/unit/nest/roadtrip.service.test.ts | 25 ++++++++- shared/src/roadtrip/roadtrip.schema.ts | 12 ++++- 8 files changed, 158 insertions(+), 15 deletions(-) diff --git a/client/src/components/Roadtrip/useRoadtripVias.ts b/client/src/components/Roadtrip/useRoadtripVias.ts index 4dc8c6f0c..b995ac08d 100644 --- a/client/src/components/Roadtrip/useRoadtripVias.ts +++ b/client/src/components/Roadtrip/useRoadtripVias.ts @@ -66,7 +66,7 @@ export interface RoadtripVias { /** Absent leaves the day's track alone, null clears it, an object records a new one. */ track?: { place_id: number; stray_km?: number | null } | null, ) => Promise - move: (dayId: number, id: number, lat: number, lng: number) => Promise + move: (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => Promise remove: (dayId: number, id: number) => Promise /** * Correct a day's anchors after its stops changed shape. @@ -171,9 +171,16 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean) await reload() }, [tripId, reload]) - const move = useCallback(async (dayId: number, id: number, lat: number, lng: number) => { + const move = useCallback(async (dayId: number, id: number, lat: number, lng: number, afterOrderIndex?: number) => { if (!tripId) return - await roadtripApi.moveVia(tripId, dayId, id, { lat, lng }) + // The anchor rides along when the caller worked out a new one: a via dragged past the + // stop it used to sit before belongs to the next leg now, and saying only where it is + // leaves it claiming the old one. + await roadtripApi.moveVia(tripId, dayId, id, { + lat, + lng, + ...(afterOrderIndex === undefined ? {} : { after_order_index: afterOrderIndex }), + }) await reload() }, [tripId, reload]) diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index e9df4a853..60ba14115 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1060,13 +1060,65 @@ describe('useTripPlanner road trip: shaping the drive on the map', () => { const { result } = await renderRoadtrip() await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) }) - expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7) + expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7, 0) rt.vias.move.mockRejectedValue(new Error('stale')) await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) }) expect(toasts.some(t => t.type === 'error' && t.message === 'stale')).toBe(true) }) + it('FE-TP-ROAD-053: a via dragged past a stop is re-pinned to the leg it landed on', async () => { + // The bug this exists for. A drag used to send only the new coordinates, so a via + // pulled beyond the stop it used to precede kept claiming the earlier leg: the route + // ran out to the point and back before carrying on, which reads as the drag doing + // nothing at all. Three stops so there are two legs to land between. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 53.85, lng: 11.45 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.87, 10.7], [53.85, 11.45], [53.87, 11.53], [52.52, 13.4]], + }] + const { result } = await renderRoadtrip() + + // Dropped on the first leg, then dragged past the middle stop onto the second. + await act(async () => { await result.current.addRoadtripVia(53.87, 10.7) }) + expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.87, 10.7) + + await act(async () => { await result.current.moveRoadtripVia(5, 9, 53.87, 11.53) }) + expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 53.87, 11.53, 1) + }) + + it('FE-TP-ROAD-054: a drag is measured against its own day, however close another one runs', async () => { + // Placing a via lets the nearest day win, which is right for a click on the map. + // A drag is not that: the via already belongs to a day, and handing it to a + // neighbouring day whose road happens to pass closer would make it vanish from the + // one it was dragged in. + twoRoutedDays() + const { result } = await renderRoadtrip() + + // Right on day 6's line, but dragged within day 5. + await act(async () => { await result.current.moveRoadtripVia(5, 3, 47.9, 12.3) }) + + expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 47.9, 12.3, expect.any(Number)) + // Day 6 never saw it. + expect(rt.vias.move).not.toHaveBeenCalledWith(6, expect.anything(), expect.anything(), expect.anything(), expect.anything()) + }) + + it('FE-TP-ROAD-055: a drag far off the road still moves, without an anchor to offer', async () => { + // No distance guard on a drag, unlike on a click: pulling a via well away from the + // current road is the whole point of the gesture. A day with no line to measure + // against simply sends no anchor, and the existing pin stays as it was. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ dayId: 5, stops: [], geometry: [] }] + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripVia(5, 3, 41.9, 12.5) }) + + expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 41.9, 12.5, undefined) + }) + it('FE-TP-ROAD-052: removing a via lets the drive take the direct road again', async () => { twoRoutedDays() const { result } = await renderRoadtrip() diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index b180a6fc2..dce17731d 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -1148,10 +1148,19 @@ export function useTripPlanner() { * third stop" means the same thing everywhere. The day is the one whose line was hit, * found by trying each day's geometry and keeping the closest. */ - const addRoadtripVia = useCallback(async (lat: number, lng: number) => { - if (!can('day_edit', trip)) return + /** + * Which stop of which day a point belongs behind, measured along the drive. + * + * Shared by placing a via and by dragging one, because it is the same question both + * times and the answer has to be recomputed both times. A drag used to send only the + * new coordinates, so a via pulled past the stop it used to precede kept claiming the + * earlier leg: the route then ran out to the point and back before carrying on, which + * looks exactly like a drag that did nothing. + */ + const anchorFor = useCallback((lat: number, lng: number, onlyDayId?: number) => { let best: { dayId: number; afterIndex: number; offRouteKm: number } | null = null for (const day of roadtripRoutes.days) { + if (onlyDayId !== undefined && day.dayId !== onlyDayId) continue if (day.geometry.length < 2) continue const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) const hit = projectOntoRoute({ lat, lng }, spine) @@ -1162,6 +1171,12 @@ export function useTripPlanner() { const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1) best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm } } + return best + }, [roadtripRoutes.days]) + + const addRoadtripVia = useCallback(async (lat: number, lng: number) => { + if (!can('day_edit', trip)) return + const best = anchorFor(lat, lng) // A click that landed on some other line is not a via anywhere. if (!best || best.offRouteKm > 2) return try { @@ -1169,17 +1184,25 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [roadtripRoutes.days, roadtripVias, can, trip, toast, t]) + }, [anchorFor, roadtripVias, can, trip, toast, t]) /** Dragging a via redraws the route through its new position. */ const moveRoadtripVia = useCallback(async (dayId: number, id: number, lat: number, lng: number) => { if (!can('day_edit', trip)) return + // Measured against this day only. A drag is a drag WITHIN a day: letting the nearest + // day win, the way placing one does, would hand the via to a neighbouring day whose + // road happens to pass closer, and it would vanish from the day it was dragged in. + // + // No distance guard either. Dragging a via well off the current road is the whole + // point of dragging it, and refusing that would be refusing the gesture; the anchor + // just says which leg gets bent, and the router answers the rest. + const anchor = anchorFor(lat, lng, dayId) try { - await roadtripVias.move(dayId, id, lat, lng) + await roadtripVias.move(dayId, id, lat, lng, anchor?.afterIndex) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [roadtripVias, can, trip, toast, t]) + }, [anchorFor, roadtripVias, can, trip, toast, t]) /** Removing a via lets the drive take the direct road again. */ const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { diff --git a/server/src/nest/roadtrip/roadtrip.controller.ts b/server/src/nest/roadtrip/roadtrip.controller.ts index abe3e1363..6bfd54994 100644 --- a/server/src/nest/roadtrip/roadtrip.controller.ts +++ b/server/src/nest/roadtrip/roadtrip.controller.ts @@ -108,7 +108,7 @@ export class RoadtripController { @Body() body: RoadtripViaUpdateDto, ): { via: RoadtripVia } { this.requireDay(dayId, tripId); - const via = this.roadtrip.move(id, dayId, body.lat, body.lng); + const via = this.roadtrip.move(id, dayId, body.lat, body.lng, body.after_order_index); if (!via) throw new HttpException({ error: 'Via not found' }, 404); return { via }; } diff --git a/server/src/nest/roadtrip/roadtrip.service.ts b/server/src/nest/roadtrip/roadtrip.service.ts index 720f8b853..cd5ae43f1 100644 --- a/server/src/nest/roadtrip/roadtrip.service.ts +++ b/server/src/nest/roadtrip/roadtrip.service.ts @@ -154,14 +154,34 @@ export class RoadtripService { } /** Moving a via is the whole edit; where it sits in the chain does not change. */ - move(id: string | number, dayId: string | number, lat: number, lng: number): RoadtripVia | null { + move( + id: string | number, + dayId: string | number, + lat: number, + lng: number, + afterOrderIndex?: number, + ): RoadtripVia | null { const existing = this.db.get<{ id: number }>( 'SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?', id, dayId, ); if (!existing) return null; - this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id); + // The anchor moves with the point when the caller worked out a new one. It is not a + // property of the via but of where the via sits along the drive, so dragging one past + // a stop changes which leg it belongs to — and leaving it behind is what made the + // route run out to the point and back instead of bending through it. + if (afterOrderIndex === undefined) { + this.db.run('UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?', lat, lng, id); + } else { + this.db.run( + 'UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?', + lat, + lng, + afterOrderIndex, + id, + ); + } return this.byId(Number(id)); } diff --git a/server/tests/unit/nest/roadtrip.controller.test.ts b/server/tests/unit/nest/roadtrip.controller.test.ts index aaed155f5..22b3cf385 100644 --- a/server/tests/unit/nest/roadtrip.controller.test.ts +++ b/server/tests/unit/nest/roadtrip.controller.test.ts @@ -160,7 +160,15 @@ describe('RoadtripController — writes', () => { it('ROADTRIP-CTL-012: moving a via reports where it went', () => { const s = svc(); expect(new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA }); - expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11); + // Undefined anchor, explicitly: a drag that stayed between the same two stops sends + // no new one, and the service must leave the existing pin alone rather than clear it. + expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, undefined); + }); + + it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', () => { + const s = svc(); + new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 }); + expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, 2); }); it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', () => { diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts index dfa73c02c..e047f7c93 100644 --- a/server/tests/unit/nest/roadtrip.service.test.ts +++ b/server/tests/unit/nest/roadtrip.service.test.ts @@ -82,7 +82,10 @@ describe('RoadtripService', () => { expect(service.listForTrip(7).map(v => v.day_id)).toEqual([1, 2]); }); - it('ROADTRIP-SVC-005: moving a via changes where it is, not where it sits in the chain', () => { + it('ROADTRIP-SVC-005: a move with no new anchor leaves the chain alone', () => { + // The drag stayed between the same two stops, so there is nothing to re-pin. The + // sequence is never touched by a move either way: it orders the vias that share one + // anchor, and dragging one does not reorder its neighbours. const via = service.create(1, { after_order_index: 1, lat: 53, lng: 10 }); const moved = service.move(via.id, 1, 52.5, 11.5); @@ -90,6 +93,26 @@ describe('RoadtripService', () => { expect(moved).toMatchObject({ lat: 52.5, lng: 11.5, after_order_index: 1, sequence: via.sequence }); }); + it('ROADTRIP-SVC-007: a via dragged past a stop is re-pinned to the leg it landed on', () => { + // The bug this exists for: a drag used to carry only the coordinates, so a via pulled + // beyond the stop it used to precede kept claiming the earlier leg. The route then ran + // out to the point and back before carrying on, which reads as the drag doing nothing. + const via = service.create(1, { after_order_index: 0, lat: 53.87, lng: 10.7 }); + + const moved = service.move(via.id, 1, 53.87, 11.53, 1); + + expect(moved).toMatchObject({ lat: 53.87, lng: 11.53, after_order_index: 1 }); + }); + + it('ROADTRIP-SVC-008: a re-pin through the wrong day changes nothing at all', () => { + // The day check has to guard the anchor as well as the coordinates, or an id from + // another day could be renumbered through a day the caller does happen to reach. + const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); + + expect(service.move(via.id, 2, 51, 12, 3)).toBeNull(); + expect(service.listForDay(1)[0]).toMatchObject({ lat: 53, lng: 10, after_order_index: 0 }); + }); + it('ROADTRIP-SVC-006: a via cannot be moved or removed through the wrong day', () => { const via = service.create(1, { after_order_index: 0, lat: 53, lng: 10 }); diff --git a/shared/src/roadtrip/roadtrip.schema.ts b/shared/src/roadtrip/roadtrip.schema.ts index 1eac31b1c..8efe3b05e 100644 --- a/shared/src/roadtrip/roadtrip.schema.ts +++ b/shared/src/roadtrip/roadtrip.schema.ts @@ -43,10 +43,20 @@ export const roadtripViaCreateRequestSchema = z.object({ }); export type RoadtripViaCreateRequest = z.infer; -/** Moving a via is the whole edit — dragging it somewhere else on the map. */ +/** + * Moving a via is the whole edit — dragging it somewhere else on the map. + * + * `after_order_index` travels with the drag because the anchor is not a property of the + * point, it is a property of where the point sits along the drive. Without it a via + * dragged past the stop it used to precede keeps claiming the earlier leg, and the route + * runs out to it and back again before carrying on — which reads as the drag having done + * nothing at all. Optional so an older client, and a drag that did not move the via + * across a stop, still send just the coordinates. + */ export const roadtripViaUpdateRequestSchema = z.object({ lat: latSchema, lng: lngSchema, + after_order_index: z.number().int().min(0).optional(), }); export type RoadtripViaUpdateRequest = z.infer; From c9e0fe52d54a9a630c50c99f564295df5cbc2dff Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 20:16:13 +0200 Subject: [PATCH 15/29] wip(roadtrip): the point where the tank runs dry, as a place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveDriveWarnings knew the distance and threw the position away. The warning is filed against the stop the car ARRIVES at, which can be most of a leg past the point the fuel actually ran out — fine for a warning, useless for a suggestion, because a filling station offered at the warning is one the car cannot reach. emptyAt is a fourth field on the return object rather than a field on each warning, for two reasons. Five tests assert warnings with toEqual and an extra defined property fails them all. And a dry point is once per TANK while a warning is once per stop: a long stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning would stack three identical offers for one fill-up down a single day. drivenMeters counts driving legs only, which is the whole reason it is counted here rather than derived later. The day's drawn line concatenates every run, ferries and walks included, while the budget skips those — so walking that line to the same figure overshoots by the length of any crossing the day contains and puts the marker out at sea. pointAtMeters walks the driving-only line instead. --- .../src/components/Roadtrip/corridor.test.ts | 59 ++++++++++ client/src/components/Roadtrip/corridor.ts | 53 +++++++++ .../components/Roadtrip/roadtripModel.test.ts | 108 ++++++++++++++++++ .../src/components/Roadtrip/roadtripModel.ts | 52 ++++++++- .../components/Roadtrip/useRoadtripRoutes.ts | 28 ++++- 5 files changed, 295 insertions(+), 5 deletions(-) diff --git a/client/src/components/Roadtrip/corridor.test.ts b/client/src/components/Roadtrip/corridor.test.ts index 4a2df1a92..97b8b1013 100644 --- a/client/src/components/Roadtrip/corridor.test.ts +++ b/client/src/components/Roadtrip/corridor.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest' import { + boxAround, corridorTiles, distanceToSegmentKm, haversineKm, + pointAtMeters, projectOntoRoute, simplifyLine, type LatLng, @@ -155,3 +157,60 @@ describe('simplifyLine', () => { expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN]) }) }) + +describe('pointAtMeters', () => { + // A straight run due east along one parallel, so the arithmetic is checkable by hand: + // at 52° north a degree of longitude is about 68.5 km. + const WEST: LatLng = { lat: 52, lng: 13 } + const EAST: LatLng = { lat: 52, lng: 14 } + const LEG = haversineKm(WEST, EAST) * 1000 + + it('lands halfway along a single segment', () => { + const mid = pointAtMeters([WEST, EAST], LEG / 2)! + expect(mid.lat).toBeCloseTo(52, 5) + expect(mid.lng).toBeCloseTo(13.5, 3) + }) + + it('walks past the first segment into the second', () => { + const line = [WEST, EAST, { lat: 52, lng: 15 }] + const at = pointAtMeters(line, LEG * 1.5)! + // One and a half segments in, so halfway through the second. + expect(at.lng).toBeGreaterThan(14) + expect(at.lng).toBeLessThan(15) + }) + + it('returns the last point past the end rather than nothing', () => { + // A tank that runs out after the day's final stop still ran out somewhere; the + // caller decides what to do about that, so this must not answer null. + expect(pointAtMeters([WEST, EAST], LEG * 10)).toEqual(EAST) + }) + + it('answers the first point for zero, and nothing for an empty line', () => { + expect(pointAtMeters([WEST, EAST], 0)).toEqual(WEST) + expect(pointAtMeters([WEST, EAST], -5)).toEqual(WEST) + expect(pointAtMeters([], 100)).toBeNull() + expect(pointAtMeters([WEST], 100)).toEqual(WEST) + }) + + it('survives a repeated vertex without dividing by zero', () => { + // A route line concatenated from several runs does contain these. + const at = pointAtMeters([WEST, WEST, EAST], LEG / 2)! + expect(at.lng).toBeCloseTo(13.5, 3) + }) +}) + +describe('boxAround', () => { + it('reaches the asked radius on all four sides', () => { + const box = boxAround(BERLIN, 10) + // North and south are the easy ones: a degree of latitude is constant. + expect(haversineKm(BERLIN, { lat: box.north, lng: BERLIN.lng })).toBeCloseTo(10, 0) + expect(haversineKm(BERLIN, { lat: BERLIN.lat, lng: box.east })).toBeCloseTo(10, 0) + }) + + it('widens the longitude span the further north it sits', () => { + // The same radius is more degrees of longitude in Tromsø than in Berlin. + const berlin = boxAround(BERLIN, 10) + const tromso = boxAround({ lat: 69.65, lng: 18.96 }, 10) + expect(tromso.east - tromso.west).toBeGreaterThan(berlin.east - berlin.west) + }) +}) diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts index a00a32760..1e769b111 100644 --- a/client/src/components/Roadtrip/corridor.ts +++ b/client/src/components/Roadtrip/corridor.ts @@ -232,3 +232,56 @@ export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] { const right = simplifyLine(line.slice(index), toleranceKm) return [...left.slice(0, -1), ...right] } + +/** + * The point that lies `metres` along a line, measured the way a car drives it. + * + * Every other helper in this file goes the other way: given a point, where on the line + * is it. This is the inverse, and it exists because the range budget produces a distance + * ("the tank is empty 340 km in") with no idea where that is on the map. + * + * Walked segment by segment rather than approximated, and interpolated inside the + * segment it lands in, because the answer becomes a marker somebody drives to. Past the + * end it returns the last point rather than null: a tank that runs out after the day's + * final stop still ran out somewhere, and the caller decides what to do about that. + */ +export function pointAtMeters(line: LatLng[], metres: number): LatLng | null { + if (!line.length) return null + if (line.length === 1 || metres <= 0) return line[0] + + let covered = 0 + for (let i = 1; i < line.length; i++) { + const step = haversineKm(line[i - 1], line[i]) * 1000 + if (covered + step >= metres) { + // How far into this segment, as a fraction. A zero-length segment (two identical + // vertices, which a concatenated route line does produce) would divide by zero, + // and its start point is the right answer anyway. + const t = step > 0 ? (metres - covered) / step : 0 + return { + lat: line[i - 1].lat + (line[i].lat - line[i - 1].lat) * t, + lng: line[i - 1].lng + (line[i].lng - line[i - 1].lng) * t, + } + } + covered += step + } + return line[line.length - 1] +} + +/** + * A box around a point, sized so its inscribed circle reaches `radiusKm`. + * + * The server turns a box into centre plus half the diagonal, so a box of ±r asks for + * r·√2 of reach and finds things in the corners that are further away than they look. + * Sized from the radius rather than guessed, and the caller still has to read the + * answer's own `clamped` flag: above 20 km the index narrows the search silently. + */ +export function boxAround(point: LatLng, radiusKm: number): Bbox { + const dLat = radiusKm / KM_PER_DEG_LAT + const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat)))) + return { + south: point.lat - dLat, + west: point.lng - dLng, + north: point.lat + dLat, + east: point.lng + dLng, + } +} diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 9dfc297d2..5d9d2a2e4 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -579,3 +579,111 @@ describe('deriveDriveWarnings — what counts as driving', () => { expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }]) }) }) + +describe('deriveDriveWarnings — where the tank actually runs dry', () => { + const drive = (minutes: number, km: number) => + ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) + const ferry = (minutes: number, km: number) => + ({ duration: minutes * 60, distance: km * 1000, mode: 'ferry' }) + + it('FE-ROADTRIP-MODEL-089: the dry point sits where the fuel ends, not where somebody notices', () => { + // 300 km of range, two legs of 200. The warning lands on stop 2, which is 400 km in; + // the tank was empty 100 km earlier, halfway through the second leg. Suggesting a + // filling station at the warning would suggest one the car cannot reach. + const out = deriveDriveWarnings( + [drive(120, 200), drive(120, 200)], + [false, false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 300 }, + 0, + ) + expect(out.warnings).toEqual([{ index: 2, code: 'range', sinceKm: 400 }]) + expect(out.emptyAt).toEqual([ + { legIndex: 1, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }, + ]) + }) + + it('FE-ROADTRIP-MODEL-090: one dry point per tank, however many warnings the stretch produces', () => { + // A long run with nothing on it warns at every stop on purpose, because a warning is + // not a fill-up. One refuel offer per warning would stack three identical offers for + // a single tank down one day. + const out = deriveDriveWarnings( + [drive(60, 400), drive(60, 400), drive(60, 400)], + [false, false, false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 600 }, + 0, + ) + expect(out.warnings).toHaveLength(2) + expect(out.emptyAt).toHaveLength(1) + expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, drivenMeters: 600000 }) + }) + + it('FE-ROADTRIP-MODEL-091: filling up starts a new tank, and the next one runs dry again', () => { + const out = deriveDriveWarnings( + [drive(60, 400), drive(60, 400), drive(60, 400)], + [false, false, true, false], + { legMinutes: null, dayMinutes: null, rangeKm: 600 }, + 0, + ) + // Empty once before the fuel stop, then once more after it. + expect(out.emptyAt).toHaveLength(1) + expect(out.emptyAt[0].drivenMeters).toBe(600000) + }) + + it('FE-ROADTRIP-MODEL-092: a ferry carries the car without burning a drop', () => { + // The trap this field exists for. The budget skips a ferry, so the dry point must be + // counted in DRIVING metres only; measuring along the day's drawn line instead would + // overshoot by the whole crossing and put the marker out at sea. + const out = deriveDriveWarnings( + [drive(60, 200), ferry(120, 50), drive(60, 200)], + [false, false, false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 300 }, + 0, + ) + expect(out.emptyAt).toEqual([ + { legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }, + ]) + }) + + it('FE-ROADTRIP-MODEL-093: a tank carried over midnight runs dry earlier the next day', () => { + const out = deriveDriveWarnings( + [drive(60, 200), drive(60, 200)], + [false, false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 300 }, + 250, + ) + // Only 50 km left on arrival, so it empties a quarter into the first leg. + expect(out.emptyAt).toEqual([ + { legIndex: 0, intoLegKm: 50, drivenMeters: 50000, sinceKm: 300 }, + ]) + }) + + it('FE-ROADTRIP-MODEL-094: no range limit and no crossing produce no dry point at all', () => { + const noLimit = deriveDriveWarnings( + [drive(60, 900)], + [false, false], + { legMinutes: null, dayMinutes: null, rangeKm: null }, + 0, + ) + expect(noLimit.emptyAt).toEqual([]) + + const withinRange = deriveDriveWarnings( + [drive(60, 100)], + [false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 300 }, + 0, + ) + expect(withinRange.emptyAt).toEqual([]) + }) + + it('FE-ROADTRIP-MODEL-095: an unrouted leg gives the tank up rather than guessing where it ends', () => { + // The budget goes null and stays null, so there is no honest dry point to offer. + const out = deriveDriveWarnings( + [drive(60, 200), undefined, drive(60, 400)], + [false, false, false, false], + { legMinutes: null, dayMinutes: null, rangeKm: 300 }, + 0, + ) + expect(out.emptyAt).toEqual([]) + expect(out.carryKm).toBeNull() + }) +}) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 753eee654..85e834fa5 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -341,6 +341,29 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number { * town. A point past the end lands in the last leg rather than nowhere, because that is * a rounding edge and not a missing leg. */ +/** + * Where a tank runs dry, in the day's own driving coordinates. + * + * A separate thing from the range WARNING, and the difference is the whole point. The + * warning is filed against the stop the car arrives at, which is where somebody finds + * out; this is where the fuel actually runs out, which can be most of a leg earlier and + * is the only place worth suggesting a filling station. + * + * `drivenMeters` counts DRIVING legs only, so it is not an offset into the day's drawn + * line: that line also carries ferry and walking runs. Converting it to a coordinate + * means walking the driving legs, which `dryPointOn` does. + */ +export interface DryPoint { + /** Index into the day's legs, i.e. the leg the car is on when the tank empties. */ + legIndex: number + /** How far into that leg, in kilometres. */ + intoLegKm: number + /** Metres of driving from the start of the day to that point. */ + drivenMeters: number + /** The range limit that was crossed, which is what the traveller set. */ + sinceKm: number +} + /** * The findings about the driving itself: too long at the wheel, too long in one day, and * the tank running out before anywhere to fill it. @@ -377,10 +400,16 @@ export function deriveDriveWarnings( limits: DriveLimits, /** Kilometres already on the tank when the day starts; null when that is unknown. */ carryKm: number | null, -): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } { +): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } { const warnings: ScheduleWarning[] = [] + const emptyAt: DryPoint[] = [] let budget = carryKm let totalSeconds = 0 + // Metres of DRIVING covered so far. Not the same as metres along the day's line: the + // line carries every run, and a ferry or a walk in the middle of a day adds to it + // without adding to this. Whatever converts a dry point back into a coordinate has to + // walk the driving legs only, which is why this is counted here rather than derived. + let drivenMeters = 0 for (let i = 0; i < legs.length; i++) { if (refuelsAt[i]) budget = 0 @@ -403,7 +432,25 @@ export function deriveDriveWarnings( if (typeof metres !== 'number') { budget = null } else if (budget !== null) { + const before = budget budget += metres / 1000 + // Where the tank actually runs dry, as opposed to where somebody notices. The + // warning below sits on the arriving stop, which can be a hundred kilometres past + // the point the fuel ran out; a suggestion has to be offered at the point, or it + // suggests filling up somewhere the car cannot reach. + // + // Once per tank, not once per warning. A long stretch with nothing on it produces + // a run of warnings on purpose (see the note above), and one refuel offer per + // warning would stack three identical offers down one day for a single fill-up. + if (limits.rangeKm && before <= limits.rangeKm && budget > limits.rangeKm) { + const intoLegKm = limits.rangeKm - before + emptyAt.push({ + legIndex: i, + intoLegKm, + drivenMeters: drivenMeters + intoLegKm * 1000, + sinceKm: Math.round(limits.rangeKm), + }) + } // Not on a stop that fills up: arriving at a petrol station with an empty tank is // the plan working, not a problem, and a warning there would sit on the one stop // that answers it. No reset either — a warning is not a fill-up, and the figure has @@ -411,6 +458,7 @@ export function deriveDriveWarnings( if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) { warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) }) } + drivenMeters += metres } } // The last stop of the day counts too: filling up on arrival is what makes the next @@ -422,7 +470,7 @@ export function deriveDriveWarnings( ? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes } : null - return { warnings, day, carryKm: budget } + return { warnings, day, carryKm: budget, emptyAt } } export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number { diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 77728647f..4dbc83d02 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,8 +3,8 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula import { resolveLegMode } from '../Planner/legMode' import { computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, - type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid,} from './roadtripModel' -import { projectOntoRoute } from './corridor' + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel' +import { projectOntoRoute, pointAtMeters} from './corridor' import { useSettingsStore } from '../../store/settingsStore' import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' import { spurFor } from './accessSpur' @@ -61,6 +61,13 @@ export interface RoadtripDay { * has to be able to say so instead of wearing a label the road disproves. */ avoidMissed?: RouteAvoidClass[] + /** + * Where the tank runs dry on this day, once per fill-up, with a coordinate. + * + * Empty when no range limit is set, when the day never crosses it, or when a leg did + * not route — the budget gives up rather than guessing, and so does this. + */ + dryPoints?: (DryPoint & { lat: number; lng: number })[] /** * The roads actually driven that day, as [lat, lng] — not the straight lines between * stops. Anything asking "what is along this day" has to use this: between Hamburg and @@ -539,8 +546,23 @@ export function useRoadtripRoutes( carryKm, ) carryKm = drive.carryKm + // Where each tank runs dry, as a place rather than a distance. + // + // Walked along the DRIVING legs only. `geometry` above is every run end to end, + // ferries and walks included, while the budget counts none of those — so walking + // the drawn line to the same figure overshoots by the whole length of any crossing + // the day happens to contain, and the marker lands a ferry's width off the road. + const drivingLine = routed + .filter(l => l && l.seg.mode !== undefined && l.seg.mode === 'driving') + .flatMap(l => l?.line ?? []) + const dryPoints = drive.emptyAt + .map(dry => { + const at = pointAtMeters(drivingLine.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters) + return at ? { ...dry, lat: at.lat, lng: at.lng } : null + }) + .filter((d): d is DryPoint & { lat: number; lng: number } => d !== null) out.push({ - ...day, stops, legs, legVias, schedule, geometry, distance, duration, + ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, driveWarnings: drive.warnings, dayWarning: drive.day, }) From 95d1752a3ddf45d767d9b84e377e2b5278b5996a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 20:41:56 +0200 Subject: [PATCH 16/29] feat(roadtrip): suggest where to fill up, not just warn that you cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The range budget has always been able to say "you have driven 245 km on this tank" and never "and here is where to do something about it". The review put it plainly: the value is in the suggestion, not in the warning. The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The fuel runs out earlier, often most of a leg earlier, and a filling station offered at the warning is one the car cannot reach. So the dry point is computed where it actually is, a search runs around it on request, and what comes back is filtered to what can be reached. Three filters, each one there because its absence gives a wrong answer rather than an untidy one. Only what lies before the tank empties, because a pump past it is on the far side of an empty tank. The detour counts double, because 8 km off the road is 16 km of driving. And a reserve is kept back, because the range figure is a round number from a manual, not a measurement. Sorted by the detour rather than by how far it gets. Measured on a real day between Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one kilometre more of range, while one 0.9 km off sat almost as late. Deduplicated by name and half a kilometre, which also came out of that measurement. OSM maps a charging site as one node per socket: the first three offers were the same "autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m apart with 450 m between the pairs, while genuinely different branches of one chain were 18 to 36 km apart — so anything from 500 m upwards separates the two cases. An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after a request that failed or was cut short states something that was never checked, and the server flags both cases itself. Offline it does not offer at all: the place write queues offline but the day assignment does not, so accepting one would leave an orphan. Nothing is searched until it is asked for, and one press is one request against a small circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to the same popup a corridor hit goes through, so the stop kind, the dwell time, the position, the via re-anchoring and the re-route are the paths that already work. The dry point counts DRIVING metres only. The day's drawn line concatenates every run, ferries and walks included, while the budget skips those, so walking that line to the same figure would overshoot by the length of any crossing and put the marker out at sea. --- .../components/Roadtrip/RoadtripSidebar.tsx | 145 +++++++++++++++++- .../Roadtrip/refuelSuggestion.test.ts | 121 +++++++++++++++ .../components/Roadtrip/refuelSuggestion.ts | 120 +++++++++++++++ .../components/Roadtrip/useRefuelSearch.ts | 92 +++++++++++ .../components/Roadtrip/useRoadtripRoutes.ts | 12 +- client/src/pages/TripPlannerPage.tsx | 5 +- .../src/pages/tripPlanner/useTripPlanner.ts | 50 +++++- shared/src/i18n/ar/roadtrip.ts | 8 + shared/src/i18n/br/roadtrip.ts | 8 + shared/src/i18n/ca/roadtrip.ts | 8 + shared/src/i18n/cs/roadtrip.ts | 8 + shared/src/i18n/de/roadtrip.ts | 8 + shared/src/i18n/en/roadtrip.ts | 8 + shared/src/i18n/es/roadtrip.ts | 8 + shared/src/i18n/fr/roadtrip.ts | 8 + shared/src/i18n/gr/roadtrip.ts | 8 + shared/src/i18n/hu/roadtrip.ts | 8 + shared/src/i18n/id/roadtrip.ts | 8 + shared/src/i18n/it/roadtrip.ts | 8 + shared/src/i18n/ja/roadtrip.ts | 8 + shared/src/i18n/ko/roadtrip.ts | 8 + shared/src/i18n/nl/roadtrip.ts | 8 + shared/src/i18n/pl/roadtrip.ts | 8 + shared/src/i18n/ru/roadtrip.ts | 8 + shared/src/i18n/sv/roadtrip.ts | 8 + shared/src/i18n/tr/roadtrip.ts | 8 + shared/src/i18n/uk/roadtrip.ts | 8 + shared/src/i18n/vi/roadtrip.ts | 8 + shared/src/i18n/zh-TW/roadtrip.ts | 8 + shared/src/i18n/zh/roadtrip.ts | 8 + 30 files changed, 723 insertions(+), 6 deletions(-) create mode 100644 client/src/components/Roadtrip/refuelSuggestion.test.ts create mode 100644 client/src/components/Roadtrip/refuelSuggestion.ts create mode 100644 client/src/components/Roadtrip/useRefuelSearch.ts diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index fd44ac820..5571b133e 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -5,6 +5,9 @@ import { type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' +import type { RefuelSearch } from './useRefuelSearch' +import type { RefuelCandidate } from './refuelSuggestion' +import type { DryPoint } from './roadtripModel' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' @@ -35,6 +38,15 @@ interface RoadtripSidebarProps { onMoveStopToDay?: (fromDayId: number, assignmentId: number, toDayId: number, toIndex: number) => void /** Asks for other ways of driving one leg (#1797). */ onAskAlternatives?: (dayId: number, legIndex: number) => void + /** + * The one-shot search for somewhere to fill up before the tank runs out. + * + * Absent leaves the range findings as they were, a warning and nothing else — which is + * also what a viewer sees, since accepting one writes a stop. + */ + refuel?: RefuelSearch + onAskRefuel?: (dayId: number, dry: DryPoint & { lat: number; lng: number }) => void + onAcceptRefuel?: (dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => void /** Which leg's alternatives are on show, so the rail can mark it. */ openAlternatives?: { dayId: number; index: number } | null /** @@ -292,6 +304,108 @@ function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement * distance and the time belong together, and it leaves the row's right edge for the * button instead of a second number. */ + +/** + * Where the tank runs out on this leg, and somewhere to do something about it. + * + * Sits under the drive band rather than on the stop that carries the range warning, + * because those are two different places: the warning is filed where somebody finds out, + * this is where the fuel actually ends. A filling station offered at the warning is one + * the car cannot reach. + * + * Nothing is searched until it is asked for. Every press is a real request against a + * shared service, which is the same reason the corridor search next door is manual. + */ +function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { + dry: DryPoint & { lat: number; lng: number } + refuel: RefuelSearch + dayId: number + onAsk: () => void + onAccept?: (poi: RefuelCandidate) => void +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const key = `${dayId}:${dry.legIndex}` + const open = refuel.openFor === key + + return ( +
+
+ + + {t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })} + + {open ? ( + + ) : ( + + )} +
+ + {open && refuel.loading ? ( + {t('roadtrip.refuel.looking')} + ) : null} + + {open && !refuel.loading && refuel.outcome ? ( + refuel.results.length ? ( +
    + {/* Three at most. This is an offer beside a plan, not a list to browse; the + corridor panel is where somebody goes to see all of them. */} + {refuel.results.slice(0, 3).map(poi => ( +
  • + + {poi.name} + + + {/* What is left when the car draws level, which is the figure that + decides whether this one is any use. The detour is in it already. */} + {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} + + {onAccept ? ( + + ) : null} +
  • + ))} +
+ ) : ( + /* Three different sentences for three different facts. "Nothing on this + stretch" after a request that failed or was cut short states something that + was never checked, and that is worse than saying nothing. */ + + {refuel.outcome === 'none' + ? t('roadtrip.refuel.none') + : refuel.outcome === 'incomplete' + ? t('roadtrip.refuel.incomplete') + : t('roadtrip.refuel.failed')} + + ) + ) : null} +
+ ) +} + function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { leg: RouteSegment | undefined /** Asks for other ways of driving this leg. Absent means the route is not editable. */ @@ -813,7 +927,7 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React * (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across * the trip would put "17" beside a pin the map calls "3". */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: { day: RoadtripDay selectedAssignmentId?: number | null onSelectStop?: (placeId: number, assignmentId: number) => void @@ -828,6 +942,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on onFollowTrack?: RoadtripSidebarProps['onFollowTrack'] viaCount?: number trackName?: string + refuel?: RefuelSearch + onAskRefuel?: RoadtripSidebarProps['onAskRefuel'] + onAcceptRefuel?: RoadtripSidebarProps['onAcceptRefuel'] + /** True while any day is still routing; the range findings are not settled until then. */ + loading?: boolean }): React.ReactElement { const { from, setFrom, dropAt, setDropAt } = drag const dragging = from?.dayId === day.dayId ? from.index : null @@ -1002,6 +1121,24 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} /> ) : null} + {/* Only on the leg the fuel actually runs out on, and only once the day has + finished routing: the warnings are republished after every routing task + and the early ones are wrong, so an offer that appears and moves while + the trip loads reads as a fault. */} + {refuel && !loading + ? (day.dryPoints ?? []) + .filter(dry => dry.legIndex === i) + .map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} + /> + )) + : null} {/* After the band, because a plugin halt happens on the drive it describes rather than before setting off. */} {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( @@ -1100,7 +1237,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: { */ export default function RoadtripSidebar({ routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay, - onSetStopKind, onFollowTrack, viaCounts, trackNames, + onSetStopKind, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel, }: RoadtripSidebarProps): React.ReactElement { const { t } = useTranslation() // One drag state for the whole rail rather than one per day: a stop that cannot leave @@ -1155,6 +1292,10 @@ export default function RoadtripSidebar({ onFollowTrack={onFollowTrack} viaCount={viaCounts?.[day.dayId] ?? 0} trackName={trackNames?.[day.dayId]} + refuel={refuel} + onAskRefuel={onAskRefuel} + onAcceptRefuel={onAcceptRefuel} + loading={routes.loading} /> ))} {routes.quietDays.map(day => ( diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts new file mode 100644 index 000000000..befbfc46e --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { reachableRefuels, outcomeOf } from './refuelSuggestion' +import type { LatLng } from './corridor' +import type { Poi } from '../Map/poiCategories' + +// A straight run due east at 52° north, where a degree of longitude is about 68.5 km. +const LINE: LatLng[] = [ + { lat: 52, lng: 13 }, + { lat: 52, lng: 14 }, + { lat: 52, lng: 15 }, +] + +const poi = (name: string, lat: number, lng: number): Poi => + ({ osm_id: `node:${name}`, name, lat, lng } as Poi) + +describe('reachableRefuels', () => { + it('FE-ROADTRIP-REFUEL-001: offers only what lies before the tank runs out', () => { + // Dry at 100 km in. A pump at 50 km is reachable; one at 130 km is on the far side + // of an empty tank, and offering it is offering a walk. + const early = poi('early', 52, 13.73) // ~50 km + const late = poi('late', 52, 14.9) // ~130 km + const out = reachableRefuels([early, late], LINE, 100) + expect(out.map(p => p.name)).toEqual(['early']) + }) + + it('FE-ROADTRIP-REFUEL-002: the detour counts twice, because it is driven out and back', () => { + // Both sit at the same point along the road, 60 km in, with 100 km of range. One is + // on the road; the other is 20 km off it, so 40 km of driving, which no longer fits + // in the 40 km that are left once the reserve is kept back. + const onRoad = poi('on-road', 52, 13.876) + const offRoad = poi('off-road', 52.18, 13.876) // ~20 km north + const out = reachableRefuels([onRoad, offRoad], LINE, 100) + expect(out.map(p => p.name)).toEqual(['on-road']) + }) + + it('FE-ROADTRIP-REFUEL-003: a reserve is kept back, so nobody is sent to the last drop', () => { + // 8 km before the dry point is inside the ten kilometre reserve. + const onTheLimit = poi('on-the-limit', 52, 14.34) // ~93 km + expect(reachableRefuels([onTheLimit], LINE, 100)).toEqual([]) + }) + + it('FE-ROADTRIP-REFUEL-004: the smallest detour comes first, however late the others sit', () => { + // Measured on a real day: the station that got furthest was 9.2 km off the road for + // one kilometre more of range. Everything in this list is already reachable, so what + // separates them is what the stop costs. + const nearRoad = poi('near-road', 52, 13.15) // on the line, early + const bigDetour = poi('big-detour', 52.05, 13.9) // ~5.5 km off, later + const out = reachableRefuels([bigDetour, nearRoad], LINE, 100) + expect(out.map(p => p.name)).toEqual(['near-road', 'big-detour']) + }) + + it('FE-ROADTRIP-REFUEL-009: with the same detour, the later one wins', () => { + // Filling up early wastes the tank that was already paid for. + const early = poi('early', 52, 13.15) + const later = poi('later', 52, 14.16) + const out = reachableRefuels([early, later], LINE, 100) + expect(out.map(p => p.name)).toEqual(['later', 'early']) + }) + + it('FE-ROADTRIP-REFUEL-005: a pump already on the plan is not suggested next to itself', () => { + // Matched on position, not on id: the same station added by hand or from another + // source carries a different osm_id. + const pump = poi('pump', 52, 13.73) + const out = reachableRefuels([pump], LINE, 100, { existing: [{ lat: 52.0005, lng: 13.7302 }] }) + expect(out).toEqual([]) + }) + + it('FE-ROADTRIP-REFUEL-006: something nowhere near the road is not on the way', () => { + const wayOff = poi('way-off', 49, 13.5) + expect(reachableRefuels([wayOff], LINE, 100)).toEqual([]) + }) +}) + +describe('outcomeOf', () => { + it('FE-ROADTRIP-REFUEL-007: no answer is not the same as no filling station', () => { + // The distinction the whole band hangs on. Saying "none on this stretch" after a + // failed request states a fact that was never checked. + expect(outcomeOf([], null)).toBe('failed') + expect(outcomeOf([], { truncated: true })).toBe('incomplete') + expect(outcomeOf([], { clamped: true })).toBe('incomplete') + expect(outcomeOf([], {})).toBe('none') + }) + + it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => { + const found = [{ name: 'x' }] as never + expect(outcomeOf(found, { truncated: true })).toBe('found') + }) +}) + +describe('reachableRefuels — one entry per station', () => { + const at = (name: string, lat: number, lng: number): Poi => + ({ osm_id: `node:${name}-${lat}-${lng}`, name, lat, lng } as Poi) + + it('FE-ROADTRIP-REFUEL-010: a charging site mapped as one node per socket is offered once', () => { + // Measured on a real day: the top three offers were the same "autostrom plus GmbH" + // three times over, because OSM maps every socket as its own node. + // The real shape of it: four nodes in two pairs, 6 m and 7 m apart, with 450 m + // between the pairs. + const sockets = [ + at('autostrom plus GmbH', 52, 13.3), + at('autostrom plus GmbH', 52.00005, 13.30005), + at('autostrom plus GmbH', 52.004, 13.3005), + at('autostrom plus GmbH', 52.00405, 13.30045), + ] + const out = reachableRefuels(sockets, LINE, 100) + expect(out).toHaveLength(1) + }) + + it('FE-ROADTRIP-REFUEL-011: the same chain in two towns stays two entries', () => { + // Name alone is not identity: a chain has a branch everywhere. + // Measured: genuinely different branches were 18 to 36 km apart. + const out = reachableRefuels([at('Aral', 52, 13.2), at('Aral', 52, 13.6)], LINE, 100) + expect(out).toHaveLength(2) + }) + + it('FE-ROADTRIP-REFUEL-012: two operators sharing one services are both offered', () => { + // Position alone is not identity either. + const out = reachableRefuels([at('Aral', 52, 13.3), at('Shell', 52.0003, 13.3)], LINE, 100) + expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell']) + }) +}) diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts new file mode 100644 index 000000000..21721c413 --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.ts @@ -0,0 +1,120 @@ +import { haversineKm, projectOntoRoute, type LatLng } from './corridor' +import type { Poi } from '../Map/poiCategories' + +/** + * Picking a filling station for a tank that is about to run out. + * + * Pure: no React, no network. The caller does the one request and hands the answers + * here, which keeps the judgement — which of these can the car actually reach — testable + * without a server. + */ + +/** A candidate, with the two figures that decide whether it is any use. */ +export interface RefuelCandidate extends Poi { + /** Kilometres along the day's driving line, in the same space as the dry point. */ + alongKm: number + /** How far off the road it sits. */ + offRouteKm: number + /** Kilometres of range still in the tank when the car draws level with it. */ + spareKm: number +} + +/** + * How much of the last drop to keep back, in kilometres. + * + * Arriving at a pump with nothing left is not a plan, and the range figure a traveller + * types is a round number from a manual rather than a measurement. Ten kilometres is + * small enough not to move the suggestion and large enough not to promise the + * impossible. + */ +const RESERVE_KM = 10 + +/** + * The stations that can actually be reached, nearest to the dry point first. + * + * Three filters, and each one exists because its absence produces a wrong answer rather + * than an untidy one: + * + * - Only what lies BEFORE the point the tank empties. A station 20 km past it is on the + * far side of an empty tank, and offering it is offering a walk. + * - The detour counts double. A pump 8 km off the road is 16 km of driving, and on a + * tank this close to empty that is the difference between arriving and not. + * - What is left has to fit in the range that remains, minus a reserve. + * + * Sorted by the detour, not by how far along the road it sits. Measured against a real + * day: the station that got furthest was 9.2 km off the road for one kilometre more of + * range, while one 0.9 km off sat almost as late. Everything in this list is already + * reachable, so what separates them is what the stop costs, and that is the detour. Ties + * go to the later one, because filling up early wastes the tank you already paid for. + */ +export function reachableRefuels( + pois: Poi[], + line: LatLng[], + dryAlongKm: number, + { existing = [] }: { existing?: { lat: number; lng: number }[] } = {}, +): RefuelCandidate[] { + const out: RefuelCandidate[] = [] + for (const poi of pois) { + const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, line) + if (!hit) continue + // Reached before the tank empties, once the detour out and back is paid for. + const spareKm = dryAlongKm - hit.alongKm - hit.offRouteKm * 2 - RESERVE_KM + if (spareKm < 0) continue + // Already planned. Matched on position rather than on id: the same pump added by + // hand, or from another source, carries a different osm_id and would otherwise be + // suggested next to itself. + if (existing.some(e => haversineKm(e, { lat: poi.lat, lng: poi.lng }) < 0.15)) continue + out.push({ ...poi, alongKm: hit.alongKm, offRouteKm: hit.offRouteKm, spareKm }) + } + return oneEach(out.sort((a, b) => a.offRouteKm - b.offRouteKm || a.spareKm - b.spareKm)) +} + +/** + * What a search actually established, as opposed to what it returned. + * + * An empty list means one of two very different things, and saying the wrong one is + * worse than saying nothing: either there is no filling station on this stretch, or we + * never managed to look. The server flags the second case itself (`truncated` when it + * hit its own ceiling, `clamped` when it narrowed the area), and a failed request is the + * third. Only `none` is a statement about the road. + */ +export type RefuelOutcome = 'found' | 'none' | 'incomplete' | 'failed' + +export function outcomeOf( + candidates: RefuelCandidate[], + answer: { truncated?: boolean; clamped?: boolean } | null, +): RefuelOutcome { + if (!answer) return 'failed' + if (candidates.length) return 'found' + return answer.truncated || answer.clamped ? 'incomplete' : 'none' +} + +/** + * One entry per filling station, not one per pump. + * + * A charging site is mapped in OSM as a node per socket and a forecourt often as one per + * pump, all with the same name a few metres apart. Deduplicating by id keeps every one of + * them, which is right on a map and wrong in a list of three offers: measured on a real + * day, the top three were the same "autostrom plus GmbH" three times over. + * + * Same name and within half a kilometre counts as the same place. Both halves matter: + * not position alone, because two operators do share a motorway services, and not name + * alone, because a chain has a branch in every town. + * + * Half a kilometre comes from the data rather than from taste. In one 25 km search the + * four nodes of one charging site sat in two pairs, 6 m and 7 m apart, with 450 m between + * the pairs — while genuinely different branches of the same chain were 18 to 36 km + * apart. Anything from 500 m to a few kilometres separates those two cases; the lower end + * is the safer place to stand. + */ +const SAME_PLACE_KM = 0.5 + +function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] { + const kept: RefuelCandidate[] = [] + for (const poi of sorted) { + const twin = kept.some(k => + k.name === poi.name && haversineKm(k, { lat: poi.lat, lng: poi.lng }) < SAME_PLACE_KM) + if (!twin) kept.push(poi) + } + return kept +} diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts new file mode 100644 index 000000000..faf83735e --- /dev/null +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -0,0 +1,92 @@ +import { useCallback, useRef, useState } from 'react' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { isEffectivelyOffline } from '../../sync/networkMode' +import { boxAround, type LatLng } from './corridor' +import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion' + +/** + * Somewhere to fill up before the tank runs out, asked for once, when asked for. + * + * Deliberately NOT automatic, for the same reason the corridor search next door is not: + * every run is a real request against a shared service. It is also not the corridor + * search itself, although it looks like a smaller version of it. Three reasons, all + * about behaviour rather than tidiness: + * + * - that search is bound to the day the panel has selected, so it cannot answer a + * warning on day five while the panel shows day one; + * - starting it clears the panel's results and the traveller's own name filter; + * - it asks up to sixteen boxes along a whole day. This question needs one small circle. + * + * One request, one day, no shared state touched. + */ + +/** How far around the dry point to look, in kilometres. */ +const LOOK_KM = 25 + +export interface RefuelSearch { + /** Which dry point is being answered, as `:`, or null when idle. */ + openFor: string | null + loading: boolean + outcome: RefuelOutcome | null + results: RefuelCandidate[] + /** Runs the one request. `key` identifies the dry point, so only one is open at a time. */ + ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise + close: () => void +} + +export function useRefuelSearch(): RefuelSearch { + const { locale } = useTranslation() + const [openFor, setOpenFor] = useState(null) + const [loading, setLoading] = useState(false) + const [outcome, setOutcome] = useState(null) + const [results, setResults] = useState([]) + const abortRef = useRef(null) + + const close = useCallback(() => { + abortRef.current?.abort() + abortRef.current = null + setOpenFor(null) + setResults([]) + setOutcome(null) + setLoading(false) + }, []) + + const ask = useCallback(async ( + key: string, + at: LatLng, + line: LatLng[], + dryAlongKm: number, + existing: LatLng[], + ) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setOpenFor(key) + setResults([]) + setOutcome(null) + + // Offline the answer would be a list of places that cannot be saved: the place write + // queues offline but the day assignment does not, so accepting one would leave an + // orphan. Say so instead of offering it. + if (isEffectivelyOffline()) { + setOutcome('failed') + return + } + + setLoading(true) + try { + const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal) + if (controller.signal.aborted) return + const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }) + setResults(candidates) + setOutcome(outcomeOf(candidates, answer)) + } catch { + if (!controller.signal.aborted) setOutcome('failed') + } finally { + if (!controller.signal.aborted) setLoading(false) + } + }, [locale]) + + return { openFor, loading, outcome, results, ask, close } +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 4dbc83d02..b0d1e310a 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -68,6 +68,11 @@ export interface RoadtripDay { * not route — the budget gives up rather than guessing, and so does this. */ dryPoints?: (DryPoint & { lat: number; lng: number })[] + /** + * `geometry` with the non-driving runs left out, which is what `dryPoints` are measured + * along. The same array as `geometry` on a day that drives the whole way. + */ + drivingGeometry?: [number, number][] /** * The roads actually driven that day, as [lat, lng] — not the straight lines between * stops. Anything asking "what is along this day" has to use this: between Hamburg and @@ -561,8 +566,13 @@ export function useRoadtripRoutes( return at ? { ...dry, lat: at.lat, lng: at.lng } : null }) .filter((d): d is DryPoint & { lat: number; lng: number } => d !== null) + // The same line the dry points are measured along, so anything offered against + // them is projected in the same space. Handed on as the SAME array when the day + // drives all the way through, which is nearly every day — only a day carrying a + // ferry or a walk pays for a second copy. + const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine out.push({ - ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, + ...day, stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry, driveWarnings: drive.warnings, dayWarning: drive.day, }) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index f13264469..514999e89 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopKind, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, - routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, @@ -549,6 +549,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined} viaCounts={roadtripViaCounts} trackNames={followTrack.namesByDay} + refuel={refuel} + onAskRefuel={askRefuel} + onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined} /> ) : ( diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index dce17731d..5a87b0139 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -24,6 +24,8 @@ import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes' import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor' import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias' +import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch' +import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion' import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack' import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives' import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays' @@ -34,8 +36,7 @@ import { reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, - reanchorAfterReorder, -} from '../../components/Roadtrip/roadtripModel' + reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -539,6 +540,7 @@ export function useTripPlanner() { // Passing no days while the mode is off keeps it inert — no routing requests, no state. const roadtripActive = !!enabledAddons.roadtrip && roadtripMode const roadtripVias = useRoadtripVias(tripId, roadtripActive) + const refuel = useRefuelSearch() const roadtripRoutes = useRoadtripRoutes( tripId, roadtripActive ? days : EMPTY_DAYS, @@ -1204,6 +1206,49 @@ export function useTripPlanner() { } }, [anchorFor, roadtripVias, can, trip, toast, t]) + /** + * Somewhere to fill up before this tank runs out. + * + * Measured against the day's DRIVING line, the same one the dry point was placed on, so + * a station's distance along the road is comparable with the distance the fuel lasts. + * The day's own places go in as well: a pump already on the plan should not be offered + * beside itself. + */ + const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => { + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng })) + if (line.length < 2) return + void refuel.ask( + `${dayId}:${dry.legIndex}`, + { lat: dry.lat, lng: dry.lng }, + line, + dry.drivenMeters / 1000, + day.stops.map(stop => ({ lat: stop.lat, lng: stop.lng })), + ) + }, [roadtripRoutes.days, refuel]) + + /** + * Accepting one hands it to the same popup a corridor hit goes through. + * + * Deliberately not a direct write: the popup is where the stop kind and the time spent + * are decided, it defaults both from the category, and every step after it — the place, + * the assignment at the right position, the via re-anchoring, the re-route — is already + * correct there and pinned by tests. A second path to the same end would be a second + * place for it to go wrong. + */ + const acceptRefuel = useCallback((dayId: number, poi: RefuelCandidate, dry: DryPoint & { lat: number; lng: number }) => { + if (!can('day_edit', trip)) return + const day = roadtripRoutes.days.find(d => d.dayId === dayId) + if (!day) return + // Before the stop the tank would have run out on, which is the leg the dry point + // names. A station reached after the day's last stop is tomorrow's problem, and + // clamping it onto the final leg would re-route the arrival through it. + const position = Math.min(dry.legIndex + 1, day.stops.length - 1) + refuel.close() + setStopDraft({ poi, dayId, position, dayNumber: day.dayNumber }) + }, [roadtripRoutes.days, refuel, can, trip]) + /** Removing a via lets the drive take the direct road again. */ const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { if (!can('day_edit', trip)) return @@ -1824,6 +1869,7 @@ export function useTripPlanner() { setRoadtripStopKind, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, + refuel, askRefuel, acceptRefuel, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 986ea8e64..8dc5a24d1 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'تجنُّب {count}', 'roadtrip.avoid.missed': 'لا مفر من {classes}', 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', + 'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}', + 'roadtrip.refuel.find': 'ابحث عن وقود', + 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…', + 'roadtrip.refuel.spare': 'يتبقى {distance}', + 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود', + 'roadtrip.refuel.none': 'لم يُعثر على شيء في المسافة التي يمكن بلوغها.', + 'roadtrip.refuel.incomplete': 'اقتُطع البحث، لذا لم تُفحص هذه المسافة بالكامل.', + 'roadtrip.refuel.failed': 'لم يستجب البحث عن الأماكن.', 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 792bfe69b..4333b3b7c 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitável', 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', + 'roadtrip.refuel.dry': 'O tanque acaba neste trecho, após {distance}', + 'roadtrip.refuel.find': 'Buscar combustível', + 'roadtrip.refuel.looking': 'Procurando ao longo da rota…', + 'roadtrip.refuel.spare': 'restam {distance}', + 'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento', + 'roadtrip.refuel.none': 'Nada encontrado no trecho alcançável.', + 'roadtrip.refuel.incomplete': 'A busca foi cortada, então este trecho não foi verificado por completo.', + 'roadtrip.refuel.failed': 'A busca de lugares não respondeu.', 'roadtrip.stop.none': 'Um destino', 'roadtrip.stay.mode.pause': 'Pausa', 'roadtrip.stay.mode.night': 'Pernoite', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index f4d692377..910f9018b 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitats', 'roadtrip.avoid.missed': '{classes} inevitable', 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', + 'roadtrip.refuel.dry': 'El dipòsit s’esgota en aquesta etapa, després de {distance}', + 'roadtrip.refuel.find': 'Cerca combustible', + 'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…', + 'roadtrip.refuel.spare': 'queden {distance}', + 'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible', + 'roadtrip.refuel.none': 'No s’ha trobat res al tram accessible.', + 'roadtrip.refuel.incomplete': 'La cerca s’ha tallat, així que aquest tram no s’ha comprovat del tot.', + 'roadtrip.refuel.failed': 'La cerca de llocs no ha respost.', 'roadtrip.stop.none': 'Una destinació', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 5782bfab8..9e9a9568d 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vynecháno', 'roadtrip.avoid.missed': '{classes} nelze objet', 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', + 'roadtrip.refuel.dry': 'Na tomto úseku dojde palivo, po {distance}', + 'roadtrip.refuel.find': 'Najít palivo', + 'roadtrip.refuel.looking': 'Hledám podél trasy…', + 'roadtrip.refuel.spare': 'zbývá {distance}', + 'roadtrip.refuel.add': 'Přidat {name} jako zastávku na palivo', + 'roadtrip.refuel.none': 'Na dosažitelném úseku nic nenalezeno.', + 'roadtrip.refuel.incomplete': 'Hledání bylo zkráceno, tento úsek tedy není zkontrolovaný celý.', + 'roadtrip.refuel.failed': 'Vyhledávání míst neodpovědělo.', 'roadtrip.stop.none': 'Cíl cesty', 'roadtrip.stay.mode.pause': 'Pauza', 'roadtrip.stay.mode.night': 'Nocleh', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index fcf9d6679..9d45278c3 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} gemieden', 'roadtrip.avoid.missed': '{classes} unvermeidbar', 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', + 'roadtrip.refuel.dry': 'Auf dieser Etappe geht der Tank leer, nach {distance}', + 'roadtrip.refuel.find': 'Tankstelle suchen', + 'roadtrip.refuel.looking': 'Suche entlang der Strecke…', + 'roadtrip.refuel.spare': 'noch {distance}', + 'roadtrip.refuel.add': '{name} als Tankstopp übernehmen', + 'roadtrip.refuel.none': 'Auf der erreichbaren Strecke nichts gefunden.', + 'roadtrip.refuel.incomplete': 'Die Suche wurde abgeschnitten, die Strecke ist also nicht vollständig geprüft.', + 'roadtrip.refuel.failed': 'Die Ortssuche hat nicht geantwortet.', 'roadtrip.stop.none': 'Ein Ziel', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Übernachten', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index f98714b89..249dfbc1a 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} avoided', 'roadtrip.avoid.missed': '{classes} unavoidable', 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', + 'roadtrip.refuel.dry': 'The tank runs out on this leg, {distance} in', + 'roadtrip.refuel.find': 'Find fuel', + 'roadtrip.refuel.looking': 'Looking along the route…', + 'roadtrip.refuel.spare': '{distance} to spare', + 'roadtrip.refuel.add': 'Add {name} as a fuel stop', + 'roadtrip.refuel.none': 'Nothing found on the reachable stretch.', + 'roadtrip.refuel.incomplete': 'The search was cut short, so this stretch was not fully checked.', + 'roadtrip.refuel.failed': 'The place search did not answer.', 'roadtrip.stop.none': 'A destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Overnight', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 14f32415b..b9d907570 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitable(s)', 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', + 'roadtrip.refuel.dry': 'El depósito se vacía en este tramo, tras {distance}', + 'roadtrip.refuel.find': 'Buscar combustible', + 'roadtrip.refuel.looking': 'Buscando a lo largo de la ruta…', + 'roadtrip.refuel.spare': 'quedan {distance}', + 'roadtrip.refuel.add': 'Añadir {name} como parada de repostaje', + 'roadtrip.refuel.none': 'No se encontró nada en el tramo alcanzable.', + 'roadtrip.refuel.incomplete': 'La búsqueda se cortó, así que este tramo no se comprobó por completo.', + 'roadtrip.refuel.failed': 'La búsqueda de lugares no respondió.', 'roadtrip.stop.none': 'Un destino', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 245059321..6b40b2232 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} évité(s)', 'roadtrip.avoid.missed': '{classes} inévitable(s)', 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', + 'roadtrip.refuel.dry': 'Le réservoir se vide sur cette étape, après {distance}', + 'roadtrip.refuel.find': 'Chercher du carburant', + 'roadtrip.refuel.looking': 'Recherche le long du trajet…', + 'roadtrip.refuel.spare': 'reste {distance}', + 'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant', + 'roadtrip.refuel.none': 'Rien trouvé sur la portion accessible.', + 'roadtrip.refuel.incomplete': 'La recherche a été tronquée, cette portion n’a donc pas été entièrement vérifiée.', + 'roadtrip.refuel.failed': 'La recherche de lieux n’a pas répondu.', 'roadtrip.stop.none': 'Une destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Nuitée', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 369c33372..87ce149a4 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} αποφεύγονται', 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', + 'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}', + 'roadtrip.refuel.find': 'Εύρεση καυσίμων', + 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…', + 'roadtrip.refuel.spare': 'μένουν {distance}', + 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού', + 'roadtrip.refuel.none': 'Δεν βρέθηκε τίποτα στο προσβάσιμο τμήμα.', + 'roadtrip.refuel.incomplete': 'Η αναζήτηση κόπηκε, οπότε το τμήμα δεν ελέγχθηκε πλήρως.', + 'roadtrip.refuel.failed': 'Η αναζήτηση τοποθεσιών δεν απάντησε.', 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 81cc19e3c..e3445d086 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kerülve', 'roadtrip.avoid.missed': '{classes} nem kerülhető', 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', + 'roadtrip.refuel.dry': 'Ezen a szakaszon fogy ki a tank, {distance} után', + 'roadtrip.refuel.find': 'Üzemanyag keresése', + 'roadtrip.refuel.looking': 'Keresés az útvonal mentén…', + 'roadtrip.refuel.spare': 'marad {distance}', + 'roadtrip.refuel.add': '{name} hozzáadása tankolási megállóként', + 'roadtrip.refuel.none': 'Az elérhető szakaszon nincs találat.', + 'roadtrip.refuel.incomplete': 'A keresés csonkult, így ez a szakasz nincs teljesen ellenőrizve.', + 'roadtrip.refuel.failed': 'A helykeresés nem válaszolt.', 'roadtrip.stop.none': 'Úti cél', 'roadtrip.stay.mode.pause': 'Szünet', 'roadtrip.stay.mode.night': 'Éjszakázás', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 9b36fafab..f6071b000 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} dihindari', 'roadtrip.avoid.missed': '{classes} tak terhindarkan', 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', + 'roadtrip.refuel.dry': 'Tangki habis di ruas ini, setelah {distance}', + 'roadtrip.refuel.find': 'Cari bahan bakar', + 'roadtrip.refuel.looking': 'Mencari di sepanjang rute…', + 'roadtrip.refuel.spare': 'sisa {distance}', + 'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar', + 'roadtrip.refuel.none': 'Tidak ada yang ditemukan di ruas yang terjangkau.', + 'roadtrip.refuel.incomplete': 'Pencarian terpotong, jadi ruas ini belum diperiksa sepenuhnya.', + 'roadtrip.refuel.failed': 'Pencarian tempat tidak menjawab.', 'roadtrip.stop.none': 'Sebuah tujuan', 'roadtrip.stay.mode.pause': 'Istirahat', 'roadtrip.stay.mode.night': 'Menginap', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 9a9127bb4..ba741a196 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitati', 'roadtrip.avoid.missed': '{classes} inevitabili', 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', + 'roadtrip.refuel.dry': 'Il serbatoio si esaurisce in questa tappa, dopo {distance}', + 'roadtrip.refuel.find': 'Cerca carburante', + 'roadtrip.refuel.looking': 'Ricerca lungo il percorso…', + 'roadtrip.refuel.spare': 'restano {distance}', + 'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante', + 'roadtrip.refuel.none': 'Niente trovato nel tratto raggiungibile.', + 'roadtrip.refuel.incomplete': 'La ricerca è stata troncata, quindi questo tratto non è stato controllato del tutto.', + 'roadtrip.refuel.failed': 'La ricerca dei luoghi non ha risposto.', 'roadtrip.stop.none': 'Una destinazione', 'roadtrip.stay.mode.pause': 'Sosta', 'roadtrip.stay.mode.night': 'Pernottamento', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 8de6f1eb1..70808dd28 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} 件を回避', 'roadtrip.avoid.missed': '{classes} は回避できず', 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', + 'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)', + 'roadtrip.refuel.find': '給油所を探す', + 'roadtrip.refuel.looking': 'ルート沿いを検索中…', + 'roadtrip.refuel.spare': '残り {distance}', + 'roadtrip.refuel.add': '{name} を給油の停車地として追加', + 'roadtrip.refuel.none': '到達できる範囲には見つかりませんでした。', + 'roadtrip.refuel.incomplete': '検索が途中で打ち切られたため、この区間は完全には調べられていません。', + 'roadtrip.refuel.failed': '場所の検索が応答しませんでした。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index d05b81de7..780ca16c7 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count}개 회피', 'roadtrip.avoid.missed': '{classes} 회피 불가', 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', + 'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)', + 'roadtrip.refuel.find': '주유소 찾기', + 'roadtrip.refuel.looking': '경로를 따라 찾는 중…', + 'roadtrip.refuel.spare': '{distance} 여유', + 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가', + 'roadtrip.refuel.none': '도달 가능한 구간에서 찾지 못했습니다.', + 'roadtrip.refuel.incomplete': '검색이 중단되어 이 구간을 완전히 확인하지 못했습니다.', + 'roadtrip.refuel.failed': '장소 검색이 응답하지 않았습니다.', 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 6219aac38..007ab2e76 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vermeden', 'roadtrip.avoid.missed': '{classes} onvermijdelijk', 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', + 'roadtrip.refuel.dry': 'De tank raakt leeg op dit traject, na {distance}', + 'roadtrip.refuel.find': 'Tankstation zoeken', + 'roadtrip.refuel.looking': 'Zoeken langs de route…', + 'roadtrip.refuel.spare': 'nog {distance}', + 'roadtrip.refuel.add': '{name} als tankstop toevoegen', + 'roadtrip.refuel.none': 'Niets gevonden op het bereikbare stuk.', + 'roadtrip.refuel.incomplete': 'De zoekopdracht werd afgekapt, dit stuk is dus niet volledig gecontroleerd.', + 'roadtrip.refuel.failed': 'De plaatszoekopdracht gaf geen antwoord.', 'roadtrip.stop.none': 'Een bestemming', 'roadtrip.stay.mode.pause': 'Pauze', 'roadtrip.stay.mode.night': 'Overnachten', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 9bba0abfe..398b36ed2 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} omijane', 'roadtrip.avoid.missed': '{classes} nie do ominięcia', 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', + 'roadtrip.refuel.dry': 'Na tym odcinku kończy się paliwo, po {distance}', + 'roadtrip.refuel.find': 'Znajdź paliwo', + 'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…', + 'roadtrip.refuel.spare': 'zostaje {distance}', + 'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo', + 'roadtrip.refuel.none': 'Nic nie znaleziono na dostępnym odcinku.', + 'roadtrip.refuel.incomplete': 'Wyszukiwanie zostało ucięte, więc odcinek nie jest sprawdzony w całości.', + 'roadtrip.refuel.failed': 'Wyszukiwarka miejsc nie odpowiedziała.', 'roadtrip.stop.none': 'Cel podróży', 'roadtrip.stay.mode.pause': 'Postój', 'roadtrip.stay.mode.night': 'Nocleg', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 194aa6885..f57ca820d 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обход', 'roadtrip.avoid.missed': '{classes} не обойти', 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', + 'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}', + 'roadtrip.refuel.find': 'Найти заправку', + 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…', + 'roadtrip.refuel.spare': 'останется {distance}', + 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки', + 'roadtrip.refuel.none': 'На доступном участке ничего не найдено.', + 'roadtrip.refuel.incomplete': 'Поиск был обрезан, поэтому участок проверен не полностью.', + 'roadtrip.refuel.failed': 'Поиск мест не ответил.', 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index f466fe0d6..8d48aa86e 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} undviks', 'roadtrip.avoid.missed': '{classes} går inte att undvika', 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', + 'roadtrip.refuel.dry': 'Tanken tar slut på den här etappen, efter {distance}', + 'roadtrip.refuel.find': 'Hitta bränsle', + 'roadtrip.refuel.looking': 'Söker längs rutten…', + 'roadtrip.refuel.spare': '{distance} kvar', + 'roadtrip.refuel.add': 'Lägg till {name} som tankstopp', + 'roadtrip.refuel.none': 'Inget hittat på den nåbara sträckan.', + 'roadtrip.refuel.incomplete': 'Sökningen kapades, så sträckan är inte helt genomsökt.', + 'roadtrip.refuel.failed': 'Platssökningen svarade inte.', 'roadtrip.stop.none': 'Ett resmål', 'roadtrip.stay.mode.pause': 'Paus', 'roadtrip.stay.mode.night': 'Övernattning', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 04eecbfd1..235c83036 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kaçınılıyor', 'roadtrip.avoid.missed': '{classes} kaçınılmaz', 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', + 'roadtrip.refuel.dry': 'Bu etapta depo bitiyor, {distance} sonra', + 'roadtrip.refuel.find': 'Yakıt bul', + 'roadtrip.refuel.looking': 'Rota boyunca aranıyor…', + 'roadtrip.refuel.spare': '{distance} kalır', + 'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin', + 'roadtrip.refuel.none': 'Ulaşılabilir bölümde bir şey bulunamadı.', + 'roadtrip.refuel.incomplete': 'Arama kesildi, bu yüzden bu bölüm tam olarak kontrol edilmedi.', + 'roadtrip.refuel.failed': 'Yer araması yanıt vermedi.', 'roadtrip.stop.none': 'Bir varış noktası', 'roadtrip.stay.mode.pause': 'Mola', 'roadtrip.stay.mode.night': 'Konaklama', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 23c3ae7d0..9f4fa7552 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обхід', 'roadtrip.avoid.missed': '{classes} не обійти', 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', + 'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}', + 'roadtrip.refuel.find': 'Знайти заправку', + 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…', + 'roadtrip.refuel.spare': 'лишиться {distance}', + 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки', + 'roadtrip.refuel.none': 'На доступній ділянці нічого не знайдено.', + 'roadtrip.refuel.incomplete': 'Пошук було обрізано, тож ділянку перевірено не повністю.', + 'roadtrip.refuel.failed': 'Пошук місць не відповів.', 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 5723bf4aa..3bc408bce 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'tránh {count}', 'roadtrip.avoid.missed': '{classes} không tránh được', 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', + 'roadtrip.refuel.dry': 'Hết xăng ở chặng này, sau {distance}', + 'roadtrip.refuel.find': 'Tìm trạm xăng', + 'roadtrip.refuel.looking': 'Đang tìm dọc tuyến đường…', + 'roadtrip.refuel.spare': 'còn {distance}', + 'roadtrip.refuel.add': 'Thêm {name} làm điểm đổ xăng', + 'roadtrip.refuel.none': 'Không tìm thấy gì trên đoạn có thể tới được.', + 'roadtrip.refuel.incomplete': 'Việc tìm kiếm bị cắt ngắn nên đoạn này chưa được kiểm tra đầy đủ.', + 'roadtrip.refuel.failed': 'Tìm kiếm địa điểm không phản hồi.', 'roadtrip.stop.none': 'Một điểm đến', 'roadtrip.stay.mode.pause': 'Nghỉ', 'roadtrip.stay.mode.night': 'Qua đêm', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 6f64cdf3a..60c93ce28 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避開 {count} 項', 'roadtrip.avoid.missed': '無法避開{classes}', 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', + 'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後', + 'roadtrip.refuel.find': '尋找加油站', + 'roadtrip.refuel.looking': '正在沿路線尋找…', + 'roadtrip.refuel.spare': '剩餘 {distance}', + 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點', + 'roadtrip.refuel.none': '在可到達的路段上沒有找到。', + 'roadtrip.refuel.incomplete': '搜尋被截斷,因此這段路線沒有完全檢查。', + 'roadtrip.refuel.failed': '地點搜尋沒有回應。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 160f0db68..81012d2c6 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -38,6 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避开 {count} 项', 'roadtrip.avoid.missed': '无法避开{classes}', 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', + 'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后', + 'roadtrip.refuel.find': '查找加油站', + 'roadtrip.refuel.looking': '正在沿路线查找…', + 'roadtrip.refuel.spare': '剩余 {distance}', + 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点', + 'roadtrip.refuel.none': '在可到达的路段上没有找到。', + 'roadtrip.refuel.incomplete': '搜索被截断,因此这段路线没有完全检查。', + 'roadtrip.refuel.failed': '地点搜索没有响应。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜', From 5b147af0b692e36b85a20a13f5b9cd6419a3d0ea Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 21:59:36 +0200 Subject: [PATCH 17/29] feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things that only showed up once it was on screen. The range budget treated a petrol station and a charger as the same thing, so an electric car pausing for coffee at a filling station had its battery refilled on paper: the warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can now be named, and then only what actually refills it counts — and the search asks for that kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the old behaviour, which is what everybody who never opens the dialog is on. The band was its own shape in a rail made of drive bands. It is one now: the same rail, the same height, the same type size, a warning tint instead of a ring, and the figures where a drive band keeps them. The buttons are icons of the same 18px square the shuffle mark uses, so the band stays one line beside its neighbours. The old range warning stood down where the band shows. Both at once said the same thing twice in the wrong order — the answer above the problem — because the warning marks the stop somebody finds out at while the band sits on the leg the fuel ends on. Two measured corrections to the search itself. The server turns a box into a circle of half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35 and came back flagged as narrowed EVERY time, which read on screen as "we could not check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before the dry point rather than on it, because only road already driven is any use — centred on the point, half the circle was spent on road nobody can reach. An answer that finds nothing now leaves the button rather than only a close. The place search is a shared public service that does time out, and "it did not answer" with no way to ask again reads as broken rather than as busy. --- .../components/Map/useMergedMapPois.test.ts | 27 +++ client/src/components/Map/useMergedMapPois.ts | 30 ++- .../Roadtrip/RoadtripLimitsCard.tsx | 30 ++- .../components/Roadtrip/RoadtripSidebar.tsx | 190 +++++++++++------- .../Roadtrip/refuelSuggestion.test.ts | 5 +- .../components/Roadtrip/refuelSuggestion.ts | 5 +- .../components/Roadtrip/roadtripModel.test.ts | 39 ++++ .../src/components/Roadtrip/roadtripModel.ts | 29 ++- .../components/Roadtrip/useRefuelSearch.ts | 56 +++++- .../components/Roadtrip/useRoadtripRoutes.ts | 9 +- client/src/pages/TripPlannerPage.tsx | 9 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 3 + .../src/pages/tripPlanner/useTripPlanner.ts | 17 +- client/src/store/settingsStore.ts | 2 + client/src/types.ts | 8 + shared/src/i18n/ar/roadtrip.ts | 8 +- shared/src/i18n/br/roadtrip.ts | 8 +- shared/src/i18n/ca/roadtrip.ts | 8 +- shared/src/i18n/cs/roadtrip.ts | 8 +- shared/src/i18n/de/roadtrip.ts | 8 +- shared/src/i18n/en/roadtrip.ts | 8 +- shared/src/i18n/es/roadtrip.ts | 8 +- shared/src/i18n/fr/roadtrip.ts | 8 +- shared/src/i18n/gr/roadtrip.ts | 8 +- shared/src/i18n/hu/roadtrip.ts | 8 +- shared/src/i18n/id/roadtrip.ts | 8 +- shared/src/i18n/it/roadtrip.ts | 8 +- shared/src/i18n/ja/roadtrip.ts | 8 +- shared/src/i18n/ko/roadtrip.ts | 8 +- shared/src/i18n/nl/roadtrip.ts | 8 +- shared/src/i18n/pl/roadtrip.ts | 8 +- shared/src/i18n/ru/roadtrip.ts | 8 +- shared/src/i18n/sv/roadtrip.ts | 8 +- shared/src/i18n/tr/roadtrip.ts | 8 +- shared/src/i18n/uk/roadtrip.ts | 8 +- shared/src/i18n/vi/roadtrip.ts | 8 +- shared/src/i18n/zh-TW/roadtrip.ts | 8 +- shared/src/i18n/zh/roadtrip.ts | 8 +- 38 files changed, 518 insertions(+), 125 deletions(-) diff --git a/client/src/components/Map/useMergedMapPois.test.ts b/client/src/components/Map/useMergedMapPois.test.ts index 1ecb7a2cd..63d880bb3 100644 --- a/client/src/components/Map/useMergedMapPois.test.ts +++ b/client/src/components/Map/useMergedMapPois.test.ts @@ -57,3 +57,30 @@ describe('useMergedMapPois', () => { expect(result.current).toBe(first) }) }) + +describe('useMergedMapPois — the refuel offers', () => { + const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi) + + it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => { + // In road trip mode this is the only channel a POI reaches the map through, so + // without it somebody is asked to accept a stop they cannot see. + const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')])) + expect(result.current.map(p => p.osm_id)).toEqual(['offer']) + }) + + it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => { + const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')])) + expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b']) + }) + + it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => { + const corridor = [poi('a')] + const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), { + initialProps: { o: [] as Poi[] }, + }) + const first = result.current + rerender({ o: [] }) + expect(result.current).toBe(first) + expect(result.current).toBe(corridor) + }) +}) diff --git a/client/src/components/Map/useMergedMapPois.ts b/client/src/components/Map/useMergedMapPois.ts index b6253671a..a82209ba6 100644 --- a/client/src/components/Map/useMergedMapPois.ts +++ b/client/src/components/Map/useMergedMapPois.ts @@ -13,16 +13,30 @@ import type { Poi } from './poiCategories' * on: a hotel found both along the route and in the viewport is one pin, and the corridor's * copy wins because it carries how far along the drive it sits. * + * `offered` is the third case: the handful of filling stations proposed where a tank runs + * out. They belong on the map for a plain reason — somebody is being asked to accept a + * stop, and a stop you cannot see is not one you can judge — but they are not a search + * result and must not survive the panel being closed, which is why they arrive separately + * rather than being folded into the corridor's own list. + * * Returns the input array itself whenever there is nothing to merge. The map redraws off * this reference, so handing it a fresh array on every render would rebuild every pin. */ -export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] { +export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] { return useMemo(() => { - if (!corridor) return explore - if (!explore.length) return corridor - if (!corridor.length) return explore - const seen = new Set(corridor.map(p => p.osm_id)) - const extra = explore.filter(p => !seen.has(p.osm_id)) - return extra.length ? [...corridor, ...extra] : corridor - }, [corridor, explore]) + const base = corridor === null ? explore : merge(corridor, explore) + return merge(base, offered) + }, [corridor, explore, offered]) +} + +/** Nothing offered, as one stable array, so the default never changes identity. */ +const NONE: Poi[] = [] + +/** `a` with everything from `b` that it does not already carry, by `osm_id`. */ +function merge(a: Poi[], b: Poi[]): Poi[] { + if (!b.length) return a + if (!a.length) return b + const seen = new Set(a.map(p => p.osm_id)) + const extra = b.filter(p => !seen.has(p.osm_id)) + return extra.length ? [...a, ...extra] : a } diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index da58cb498..82baaf3b4 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react' -import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react' +import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react' import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { convertDistance, formatDistance } from '../../utils/units' @@ -103,6 +104,13 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: { ) } +/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */ +const VEHICLES: { key: 'combustion' | 'electric' | null; labelKey: string; Icon: typeof Car }[] = [ + { key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car }, + { key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel }, + { key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap }, +] + export default function RoadtripLimitsCard({ onSave }: { /** * Persists one setting. Absent leaves the dialog read-only. @@ -123,6 +131,7 @@ export default function RoadtripLimitsCard({ onSave }: { // Parsed rather than trusted: a per-user setting gets no server-side validation, and // an unknown word here would become a costing option the router does not have. + const vehicle = settings.roadtrip_vehicle ?? '' const avoiding = parseAvoid(settings.roadtrip_avoid) // No second engine, no avoidance. An instance pointed at its own OSRM has one, and its // car profile is built without excludable classes on every public host. @@ -234,6 +243,25 @@ export default function RoadtripLimitsCard({ onSave }: { placeholder={t('roadtrip.limit.off')} onChange={v => onSave?.('roadtrip_day_minutes', v)} /> + {/* Above the range, because it decides what the range even means: with no + vehicle named, a petrol station and a charger both fill the tank, which + is arithmetic that is wrong for everybody who drives just one of them. */} + s.settings.distance_unit) const key = `${dayId}:${dry.legIndex}` const open = refuel.openFor === key + const settled = open && !refuel.loading && refuel.outcome return ( -
-
- - - {t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })} - - {open ? ( - - ) : ( - - )} +
+ + + +
+ {/* Built as a drive band, because that is what it belongs to: the leg the fuel + runs out on. Same height, same type, same rail. The warning ring is the only + difference, and it is the whole message — this is the band where the tank + ends. */} +
+
+ + + {t('roadtrip.refuel.dry')} + + {/* How far INTO this leg, where a drive band keeps its figures. Not the range + that was crossed: that is the traveller's own setting, says nothing about + where, and made every band on a day read the same number. */} + + {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} + + {/* An icon, the size of the shuffle mark a drive band carries, because the + band has to stay one line high beside its neighbours. What it does is in + the tooltip and in the label a screen reader gets. */} + {open && (refuel.loading || refuel.results.length) ? ( + + + + ) : ( + /* An answer that found nothing leaves the button, not a dead end. The place + search is a shared public service that does time out, and "it did not + answer" with no way to ask again reads as broken rather than as busy. */ + + + + )} +
+ + {open && refuel.loading ? ( + {t('roadtrip.refuel.looking')} + ) : null} + + {settled ? ( + refuel.results.length ? ( +
    + {/* Three at most. This is an offer beside a plan, not a list to browse; + the corridor panel is where somebody goes to see all of them. */} + {refuel.results.slice(0, 3).map(poi => ( +
  • + + {poi.name} + + + {/* What is left when the car draws level, the figure that decides + whether this one is any use. The detour is already in it. */} + {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} + + {onAccept ? ( + + ) : null} +
  • + ))} +
+ ) : ( + /* Three different sentences for three different facts. "Nothing on this + stretch" after a request that failed or was cut short states something + that was never checked, which is worse than saying nothing. */ + + {refuel.outcome === 'none' + ? t('roadtrip.refuel.none') + : refuel.outcome === 'incomplete' + ? t('roadtrip.refuel.incomplete') + : t('roadtrip.refuel.failed')} + + ) + ) : null} +
- - {open && refuel.loading ? ( - {t('roadtrip.refuel.looking')} - ) : null} - - {open && !refuel.loading && refuel.outcome ? ( - refuel.results.length ? ( -
    - {/* Three at most. This is an offer beside a plan, not a list to browse; the - corridor panel is where somebody goes to see all of them. */} - {refuel.results.slice(0, 3).map(poi => ( -
  • - - {poi.name} - - - {/* What is left when the car draws level, which is the figure that - decides whether this one is any use. The detour is in it already. */} - {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} - - {onAccept ? ( - - ) : null} -
  • - ))} -
- ) : ( - /* Three different sentences for three different facts. "Nothing on this - stretch" after a request that failed or was cut short states something that - was never checked, and that is worse than saying nothing. */ - - {refuel.outcome === 'none' - ? t('roadtrip.refuel.none') - : refuel.outcome === 'incomplete' - ? t('roadtrip.refuel.incomplete') - : t('roadtrip.refuel.failed')} - - ) - ) : null}
) } @@ -956,6 +985,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const last = day.stops.length - 1 + /** + * The findings a stop wears, minus the one the refuel band already says better. + * + * The range warning marks the stop somebody FINDS OUT at and counts the whole tank; + * the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both + * at once says the same thing twice, in the wrong order — the answer above the problem + * — so the warning stands down wherever the band is showing. + */ + const showingBand = !!refuel && !loading && !!day.dryPoints?.length + const findingsFor = (i: number) => + day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 @@ -1091,7 +1131,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on stop={stop} entry={day.schedule.entries[i]} late={marks.find(w => w.code === 'late')} - driveFindings={day.driveWarnings.filter(w => w.index === i)} + driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} @@ -1103,7 +1143,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on number={counted} entry={day.schedule.entries[i]} late={marks.find(w => w.code === 'late')} - driveFindings={day.driveWarnings.filter(w => w.index === i)} + driveFindings={findingsFor(i)} selected={selectedAssignmentId === stop.assignmentId} continues={i < last} starts={i === 0} diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts index befbfc46e..886518769 100644 --- a/client/src/components/Roadtrip/refuelSuggestion.test.ts +++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts @@ -77,8 +77,11 @@ describe('outcomeOf', () => { // failed request states a fact that was never checked. expect(outcomeOf([], null)).toBe('failed') expect(outcomeOf([], { truncated: true })).toBe('incomplete') - expect(outcomeOf([], { clamped: true })).toBe('incomplete') expect(outcomeOf([], {})).toBe('none') + // `clamped` is not the same thing: it means the server searched a smaller circle + // than the box described, and the caller sizes the box so that never happens. + // Treating it as "could not check" turned every empty answer into a shrug. + expect(outcomeOf([], { clamped: true })).toBe('none') }) it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => { diff --git a/client/src/components/Roadtrip/refuelSuggestion.ts b/client/src/components/Roadtrip/refuelSuggestion.ts index 21721c413..9daf24b80 100644 --- a/client/src/components/Roadtrip/refuelSuggestion.ts +++ b/client/src/components/Roadtrip/refuelSuggestion.ts @@ -86,7 +86,10 @@ export function outcomeOf( ): RefuelOutcome { if (!answer) return 'failed' if (candidates.length) return 'found' - return answer.truncated || answer.clamped ? 'incomplete' : 'none' + // `truncated` alone. `clamped` only means the server searched a smaller circle than + // the box described, which the caller now sizes so it never happens — and reporting it + // as "we could not check" turned every empty answer into a shrug. + return answer.truncated ? 'incomplete' : 'none' } /** diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 5d9d2a2e4..f42cf94f2 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -11,6 +11,7 @@ import { parseClock, splitIntoRuns, sumLegSeconds, + refuelStopTypeFor, } from './roadtripModel' describe('formatDurationShort', () => { @@ -687,3 +688,41 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { expect(out.carryKm).toBeNull() }) }) + +describe('refuelsRange — what fills which tank', () => { + it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => { + // What TREK did before the setting existed, and the right answer for somebody who + // never opened the dialog. Anything else would quietly change their warnings. + expect(refuelsRange('fuel')).toBe(true) + expect(refuelsRange('charging')).toBe(true) + expect(refuelsRange('fuel', null)).toBe(true) + expect(refuelsRange('charging', null)).toBe(true) + }) + + it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => { + // The bug this exists for: an electric car pausing at a petrol station had its + // battery refilled on paper, the warnings went quiet for the rest of the day, and + // the driver was told nothing. + expect(refuelsRange('charging', 'electric')).toBe(true) + expect(refuelsRange('fuel', 'electric')).toBe(false) + }) + + it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => { + expect(refuelsRange('fuel', 'combustion')).toBe(true) + expect(refuelsRange('charging', 'combustion')).toBe(false) + }) + + it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => { + for (const vehicle of [null, 'combustion', 'electric'] as const) { + expect(refuelsRange('rest_area', vehicle)).toBe(false) + expect(refuelsRange('restaurant', vehicle)).toBe(false) + expect(refuelsRange(null, vehicle)).toBe(false) + } + }) + + it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => { + expect(refuelStopTypeFor('combustion')).toEqual(['fuel']) + expect(refuelStopTypeFor('electric')).toEqual(['charging']) + expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging']) + }) +}) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 85e834fa5..91fed9b37 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -123,9 +123,32 @@ export interface ScheduleWarning { */ export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const -/** Whether stopping here starts the range budget over. */ -export function refuelsRange(stopType: string | null | undefined): boolean { - return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '') +/** + * What the traveller drives, which decides what actually fills the tank. + * + * Absent means both count, which is what TREK did before the setting existed and is the + * right answer for somebody who never said. It is only wrong once a vehicle IS named: + * a petrol station does not charge a battery, and a charger does not fill a tank. + */ +export type VehicleKind = 'combustion' | 'electric' + +/** The stop kind that refills the named vehicle, for filtering a search. */ +export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] { + if (vehicle === 'combustion') return ['fuel'] + if (vehicle === 'electric') return ['charging'] + return REFUELLING_STOP_TYPES +} + +/** + * Whether stopping here starts the range budget over. + * + * With no vehicle named, either kind does — a plain reading of "I filled up". Once one is + * named the other stops counting, because the alternative is arithmetic that is simply + * wrong: an electric car pausing at a petrol station had its battery refilled on paper, + * the warnings went quiet for the rest of the day, and the driver was told nothing. + */ +export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean { + return refuelStopTypeFor(vehicle).includes(stopType ?? '') } /** diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts index faf83735e..1d6ad2413 100644 --- a/client/src/components/Roadtrip/useRefuelSearch.ts +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -1,8 +1,10 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { mapsApi } from '../../api/client' import { useTranslation } from '../../i18n' import { isEffectivelyOffline } from '../../sync/networkMode' -import { boxAround, type LatLng } from './corridor' +import { useSettingsStore } from '../../store/settingsStore' +import { refuelStopTypeFor, type VehicleKind } from './roadtripModel' +import { boxAround, pointAtMeters, type LatLng } from './corridor' import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion' /** @@ -21,8 +23,27 @@ import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } * One request, one day, no shared state touched. */ -/** How far around the dry point to look, in kilometres. */ -const LOOK_KM = 25 +/** + * How far around the search point to look, in kilometres. + * + * Fourteen and not more, because the server turns a box into a circle of half its + * diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which + * is the largest question it answers in full — ±25 asked for 35 and came back flagged + * as narrowed every single time, which then read on screen as "we could not check". + */ +const LOOK_KM = 14 + +/** + * How far BEFORE the dry point to centre that circle, in kilometres. + * + * Only the road already driven is any use: a station past the point is on the far side + * of an empty tank. Centring on the dry point spends half the circle on road nobody can + * reach, so it is pulled back far enough that the useful half becomes the whole of it. + */ +const LOOK_BACK_KM = 12 + +/** Nothing on offer, as one stable array, so an idle search never redraws the map. */ +const NONE: RefuelCandidate[] = [] export interface RefuelSearch { /** Which dry point is being answered, as `:`, or null when idle. */ @@ -30,6 +51,15 @@ export interface RefuelSearch { loading: boolean outcome: RefuelOutcome | null results: RefuelCandidate[] + /** + * The ones on offer, for the map to draw. + * + * The same three the rail lists and no more: somebody is being asked to accept a stop, + * and a stop that cannot be seen is not one that can be judged. Empty while nothing is + * open, and the same array each time, because the map rebuilds every pin when this + * reference changes. + */ + offered: RefuelCandidate[] /** Runs the one request. `key` identifies the dry point, so only one is open at a time. */ ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise close: () => void @@ -37,6 +67,9 @@ export interface RefuelSearch { export function useRefuelSearch(): RefuelSearch { const { locale } = useTranslation() + // What to look for. Somebody who said they drive an electric car has no use for a + // petrol station in the list, and the other way round; with nothing said, both. + const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) const [openFor, setOpenFor] = useState(null) const [loading, setLoading] = useState(false) const [outcome, setOutcome] = useState(null) @@ -76,7 +109,14 @@ export function useRefuelSearch(): RefuelSearch { setLoading(true) try { - const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal) + const kind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null + const wanted = refuelStopTypeFor(kind).join(',') + // Pulled back along the road, because only what lies before the dry point can be + // reached. `at` stays the dry point itself: it is what the band names and what the + // map is asked to show. + const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at + const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal) if (controller.signal.aborted) return const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing }) setResults(candidates) @@ -86,7 +126,9 @@ export function useRefuelSearch(): RefuelSearch { } finally { if (!controller.signal.aborted) setLoading(false) } - }, [locale]) + }, [locale, vehicle]) - return { openFor, loading, outcome, results, ask, close } + const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results]) + + return { openFor, loading, outcome, results, offered, ask, close } } diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index b0d1e310a..99042655d 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -3,7 +3,7 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula import { resolveLegMode } from '../Planner/legMode' import { computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, - type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel' + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel' import { projectOntoRoute, pointAtMeters} from './corridor' import { useSettingsStore } from '../../store/settingsStore' import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' @@ -283,8 +283,13 @@ export function useRoadtripRoutes( * any value — and an unknown word here would become a costing option the router does * not have. */ + const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid) const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) + // Validated rather than trusted, like the avoidance above: a per-user setting gets no + // server-side check, and an unknown word here would silently stop both kinds counting. + const vehicleKind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null const avoidKey = avoid.join(',') // Zero and absent both mean "no limit": zero is a legal thing to type and says the // same thing, so it is folded here rather than guarded at every reading. @@ -546,7 +551,7 @@ export function useRoadtripRoutes( legs, // One longer than the legs: the last stop of the day counts too, because filling // up on arrival is what makes the next morning start full. - stops.map(s => refuelsRange(s.stopType)), + stops.map(s => refuelsRange(s.stopType, vehicleKind)), limits, carryKm, ) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 514999e89..8d0ce48b9 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopKind, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, - routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, @@ -325,7 +325,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null { // drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the // two answers are drawn side by side rather than one hiding the other. const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false - const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois) + // The refuel offers ride the same channel: in road trip mode this is the only way a + // POI reaches the map, so without them somebody is asked to accept a stop they cannot + // see. They vanish with the offer rather than lingering as a search result. + const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered) // Costs expense editor opened from a booking modal (save-then-open). Lives at the // page level so it has tripMembers / base currency / current user available. @@ -443,7 +446,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined} roadtripVias={roadtripActive ? roadtripVias.byDay : undefined} alternativeRoutes={alternativeOverlays} - focusPoints={alternativeFocusPoints} + focusPoints={mapFocusPoints} clusterLoosely={roadtripActive} activeAlternative={highlightedAlternative} onChooseAlternative={chooseRouteAlternative} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 1c7b09788..1cc0b59ba 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -150,6 +150,9 @@ function baseState(): HookState { roadtripActive: false, roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 }, roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() }, + refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() }, + askRefuel: vi.fn(), + acceptRefuel: vi.fn(), followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false }, roadtripViaCounts: {}, stopDraft: null, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 5a87b0139..5dc36c36e 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -1075,6 +1075,21 @@ export function useTripPlanner() { [alternativeOverlays], ) + /** + * What the map should bring into view. + * + * Refuel offers win while they are open, and for the reason they exist at all: somebody + * is being asked to accept a stop, and a stop off the edge of the map cannot be judged. + * They are the newer, smaller and more specific answer, so they take the view from the + * alternatives rather than being averaged with them into a frame that shows neither. + */ + const mapFocusPoints = useMemo<[number, number][]>( + () => (refuel.offered.length + ? refuel.offered.map(p => [p.lat, p.lng] as [number, number]) + : alternativeFocusPoints), + [refuel.offered, alternativeFocusPoints], + ) + /** Asks the router for other ways of driving one leg of one day. */ const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => { const day = roadtripRoutes.days.find(d => d.dayId === dayId) @@ -1870,7 +1885,7 @@ export function useTripPlanner() { saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, refuel, askRefuel, acceptRefuel, - routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, stayDraft, setStayDraft, setRoadtripStay, highlightedAlternative, setHighlightedAlternative, moveRoadtripStopToDay, diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index d9c2f0169..58282177e 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -64,6 +64,8 @@ export const DEFAULT_SETTINGS: Settings = { // Empty = the public Valhalla, unless routing_base_url names an own router, in which // case no second engine is asked at all. See valhallaBase(). valhalla_base_url: '', + // Empty = not said, and then a stop of either kind fills up. See refuelsRange(). + roadtrip_vehicle: '', dark_mode: false, // Empty = no personal display currency, so Costs falls back to the trip's own. default_currency: '', diff --git a/client/src/types.ts b/client/src/types.ts index 67a48079c..d5b014097 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -159,6 +159,14 @@ export interface Settings { * because it goes to the router as one request either way. */ roadtrip_avoid?: string + /** + * What the traveller drives: 'combustion', 'electric', or absent for "did not say". + * + * Absent has to mean BOTH kinds refill, which is what the range budget did before this + * existed. Anything else would quietly change the warnings of every traveller who never + * opened the dialog. + */ + roadtrip_vehicle?: string /** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */ carto_api_key?: string mapbox_access_token?: string diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 8dc5a24d1..3d999bda8 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'تجنُّب {count}', 'roadtrip.avoid.missed': 'لا مفر من {classes}', 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', - 'roadtrip.refuel.dry': 'ينفد الخزان في هذه المرحلة، بعد {distance}', + 'roadtrip.refuel.dry': 'ينفد الخزان هنا', + 'roadtrip.refuel.after': 'بعد {distance}', + 'roadtrip.limit.vehicleLabel': 'بماذا تقود', + 'roadtrip.limit.vehicleAny': 'كلاهما', + 'roadtrip.limit.vehicleCombustion': 'وقود', + 'roadtrip.limit.vehicleElectric': 'كهرباء', 'roadtrip.refuel.find': 'ابحث عن وقود', + 'roadtrip.refuel.again': 'حاول مجددًا', 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…', 'roadtrip.refuel.spare': 'يتبقى {distance}', 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 4333b3b7c..e684d0781 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitável', 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', - 'roadtrip.refuel.dry': 'O tanque acaba neste trecho, após {distance}', + 'roadtrip.refuel.dry': 'Aqui o tanque acaba', + 'roadtrip.refuel.after': 'após {distance}', + 'roadtrip.limit.vehicleLabel': 'O que você dirige', + 'roadtrip.limit.vehicleAny': 'Ambos', + 'roadtrip.limit.vehicleCombustion': 'Combustível', + 'roadtrip.limit.vehicleElectric': 'Elétrico', 'roadtrip.refuel.find': 'Buscar combustível', + 'roadtrip.refuel.again': 'Tentar de novo', 'roadtrip.refuel.looking': 'Procurando ao longo da rota…', 'roadtrip.refuel.spare': 'restam {distance}', 'roadtrip.refuel.add': 'Adicionar {name} como parada de abastecimento', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 910f9018b..ff6826f23 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitats', 'roadtrip.avoid.missed': '{classes} inevitable', 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', - 'roadtrip.refuel.dry': 'El dipòsit s’esgota en aquesta etapa, després de {distance}', + 'roadtrip.refuel.dry': 'Aquí s’esgota el dipòsit', + 'roadtrip.refuel.after': 'després de {distance}', + 'roadtrip.limit.vehicleLabel': 'Què condueixes', + 'roadtrip.limit.vehicleAny': 'Tots dos', + 'roadtrip.limit.vehicleCombustion': 'Benzina', + 'roadtrip.limit.vehicleElectric': 'Elèctric', 'roadtrip.refuel.find': 'Cerca combustible', + 'roadtrip.refuel.again': 'Torna-ho a provar', 'roadtrip.refuel.looking': 'Cercant al llarg de la ruta…', 'roadtrip.refuel.spare': 'queden {distance}', 'roadtrip.refuel.add': 'Afegeix {name} com a parada de combustible', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 9e9a9568d..b065b4d41 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vynecháno', 'roadtrip.avoid.missed': '{classes} nelze objet', 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', - 'roadtrip.refuel.dry': 'Na tomto úseku dojde palivo, po {distance}', + 'roadtrip.refuel.dry': 'Tady dojde palivo', + 'roadtrip.refuel.after': 'po {distance}', + 'roadtrip.limit.vehicleLabel': 'Čím jezdíš', + 'roadtrip.limit.vehicleAny': 'Obojí', + 'roadtrip.limit.vehicleCombustion': 'Palivo', + 'roadtrip.limit.vehicleElectric': 'Elektro', 'roadtrip.refuel.find': 'Najít palivo', + 'roadtrip.refuel.again': 'Zkusit znovu', 'roadtrip.refuel.looking': 'Hledám podél trasy…', 'roadtrip.refuel.spare': 'zbývá {distance}', 'roadtrip.refuel.add': 'Přidat {name} jako zastávku na palivo', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 9d45278c3..e9461f66d 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} gemieden', 'roadtrip.avoid.missed': '{classes} unvermeidbar', 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', - 'roadtrip.refuel.dry': 'Auf dieser Etappe geht der Tank leer, nach {distance}', + 'roadtrip.refuel.dry': 'Hier ist der Tank leer', + 'roadtrip.refuel.after': 'nach {distance}', + 'roadtrip.limit.vehicleLabel': 'Womit du fährst', + 'roadtrip.limit.vehicleAny': 'Beides', + 'roadtrip.limit.vehicleCombustion': 'Sprit', + 'roadtrip.limit.vehicleElectric': 'Strom', 'roadtrip.refuel.find': 'Tankstelle suchen', + 'roadtrip.refuel.again': 'Nochmal', 'roadtrip.refuel.looking': 'Suche entlang der Strecke…', 'roadtrip.refuel.spare': 'noch {distance}', 'roadtrip.refuel.add': '{name} als Tankstopp übernehmen', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 249dfbc1a..e28a88682 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} avoided', 'roadtrip.avoid.missed': '{classes} unavoidable', 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', - 'roadtrip.refuel.dry': 'The tank runs out on this leg, {distance} in', + 'roadtrip.refuel.dry': 'Tank runs out here', + 'roadtrip.refuel.after': 'after {distance}', + 'roadtrip.limit.vehicleLabel': 'What you drive', + 'roadtrip.limit.vehicleAny': 'Either', + 'roadtrip.limit.vehicleCombustion': 'Petrol', + 'roadtrip.limit.vehicleElectric': 'Electric', 'roadtrip.refuel.find': 'Find fuel', + 'roadtrip.refuel.again': 'Try again', 'roadtrip.refuel.looking': 'Looking along the route…', 'roadtrip.refuel.spare': '{distance} to spare', 'roadtrip.refuel.add': 'Add {name} as a fuel stop', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index b9d907570..67ae0e0cb 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitados', 'roadtrip.avoid.missed': '{classes} inevitable(s)', 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', - 'roadtrip.refuel.dry': 'El depósito se vacía en este tramo, tras {distance}', + 'roadtrip.refuel.dry': 'Aquí se vacía el depósito', + 'roadtrip.refuel.after': 'tras {distance}', + 'roadtrip.limit.vehicleLabel': 'Qué conduces', + 'roadtrip.limit.vehicleAny': 'Ambos', + 'roadtrip.limit.vehicleCombustion': 'Gasolina', + 'roadtrip.limit.vehicleElectric': 'Eléctrico', 'roadtrip.refuel.find': 'Buscar combustible', + 'roadtrip.refuel.again': 'Reintentar', 'roadtrip.refuel.looking': 'Buscando a lo largo de la ruta…', 'roadtrip.refuel.spare': 'quedan {distance}', 'roadtrip.refuel.add': 'Añadir {name} como parada de repostaje', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 6b40b2232..32f9e2f57 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} évité(s)', 'roadtrip.avoid.missed': '{classes} inévitable(s)', 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', - 'roadtrip.refuel.dry': 'Le réservoir se vide sur cette étape, après {distance}', + 'roadtrip.refuel.dry': 'Réservoir vide ici', + 'roadtrip.refuel.after': 'après {distance}', + 'roadtrip.limit.vehicleLabel': 'Ce que vous conduisez', + 'roadtrip.limit.vehicleAny': 'Les deux', + 'roadtrip.limit.vehicleCombustion': 'Carburant', + 'roadtrip.limit.vehicleElectric': 'Électrique', 'roadtrip.refuel.find': 'Chercher du carburant', + 'roadtrip.refuel.again': 'Réessayer', 'roadtrip.refuel.looking': 'Recherche le long du trajet…', 'roadtrip.refuel.spare': 'reste {distance}', 'roadtrip.refuel.add': 'Ajouter {name} comme arrêt carburant', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 87ce149a4..e8852eaaf 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} αποφεύγονται', 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', - 'roadtrip.refuel.dry': 'Το ρεζερβουάρ αδειάζει σε αυτό το σκέλος, μετά από {distance}', + 'roadtrip.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ', + 'roadtrip.refuel.after': 'μετά από {distance}', + 'roadtrip.limit.vehicleLabel': 'Τι οδηγείς', + 'roadtrip.limit.vehicleAny': 'Και τα δύο', + 'roadtrip.limit.vehicleCombustion': 'Βενζίνη', + 'roadtrip.limit.vehicleElectric': 'Ηλεκτρικό', 'roadtrip.refuel.find': 'Εύρεση καυσίμων', + 'roadtrip.refuel.again': 'Ξανά', 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…', 'roadtrip.refuel.spare': 'μένουν {distance}', 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index e3445d086..0c07ed8c1 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kerülve', 'roadtrip.avoid.missed': '{classes} nem kerülhető', 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', - 'roadtrip.refuel.dry': 'Ezen a szakaszon fogy ki a tank, {distance} után', + 'roadtrip.refuel.dry': 'Itt fogy ki a tank', + 'roadtrip.refuel.after': '{distance} után', + 'roadtrip.limit.vehicleLabel': 'Mivel utazol', + 'roadtrip.limit.vehicleAny': 'Mindkettő', + 'roadtrip.limit.vehicleCombustion': 'Üzemanyag', + 'roadtrip.limit.vehicleElectric': 'Elektromos', 'roadtrip.refuel.find': 'Üzemanyag keresése', + 'roadtrip.refuel.again': 'Újra', 'roadtrip.refuel.looking': 'Keresés az útvonal mentén…', 'roadtrip.refuel.spare': 'marad {distance}', 'roadtrip.refuel.add': '{name} hozzáadása tankolási megállóként', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index f6071b000..169b4a2f2 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} dihindari', 'roadtrip.avoid.missed': '{classes} tak terhindarkan', 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', - 'roadtrip.refuel.dry': 'Tangki habis di ruas ini, setelah {distance}', + 'roadtrip.refuel.dry': 'Tangki habis di sini', + 'roadtrip.refuel.after': 'setelah {distance}', + 'roadtrip.limit.vehicleLabel': 'Kamu mengemudi apa', + 'roadtrip.limit.vehicleAny': 'Keduanya', + 'roadtrip.limit.vehicleCombustion': 'Bensin', + 'roadtrip.limit.vehicleElectric': 'Listrik', 'roadtrip.refuel.find': 'Cari bahan bakar', + 'roadtrip.refuel.again': 'Coba lagi', 'roadtrip.refuel.looking': 'Mencari di sepanjang rute…', 'roadtrip.refuel.spare': 'sisa {distance}', 'roadtrip.refuel.add': 'Tambahkan {name} sebagai perhentian isi bahan bakar', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index ba741a196..bccf14109 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} evitati', 'roadtrip.avoid.missed': '{classes} inevitabili', 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', - 'roadtrip.refuel.dry': 'Il serbatoio si esaurisce in questa tappa, dopo {distance}', + 'roadtrip.refuel.dry': 'Qui il serbatoio si esaurisce', + 'roadtrip.refuel.after': 'dopo {distance}', + 'roadtrip.limit.vehicleLabel': 'Cosa guidi', + 'roadtrip.limit.vehicleAny': 'Entrambi', + 'roadtrip.limit.vehicleCombustion': 'Benzina', + 'roadtrip.limit.vehicleElectric': 'Elettrico', 'roadtrip.refuel.find': 'Cerca carburante', + 'roadtrip.refuel.again': 'Riprova', 'roadtrip.refuel.looking': 'Ricerca lungo il percorso…', 'roadtrip.refuel.spare': 'restano {distance}', 'roadtrip.refuel.add': 'Aggiungi {name} come sosta carburante', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 70808dd28..d592111b0 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} 件を回避', 'roadtrip.avoid.missed': '{classes} は回避できず', 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', - 'roadtrip.refuel.dry': 'この区間で燃料が尽きます({distance} 走行後)', + 'roadtrip.refuel.dry': 'ここで燃料切れ', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '何で走るか', + 'roadtrip.limit.vehicleAny': '両方', + 'roadtrip.limit.vehicleCombustion': 'ガソリン', + 'roadtrip.limit.vehicleElectric': '電気', 'roadtrip.refuel.find': '給油所を探す', + 'roadtrip.refuel.again': 'もう一度', 'roadtrip.refuel.looking': 'ルート沿いを検索中…', 'roadtrip.refuel.spare': '残り {distance}', 'roadtrip.refuel.add': '{name} を給油の停車地として追加', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 780ca16c7..c4cfe59d8 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count}개 회피', 'roadtrip.avoid.missed': '{classes} 회피 불가', 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', - 'roadtrip.refuel.dry': '이 구간에서 연료가 떨어집니다 ({distance} 주행 후)', + 'roadtrip.refuel.dry': '여기서 연료 소진', + 'roadtrip.refuel.after': '{distance} 뒤', + 'roadtrip.limit.vehicleLabel': '무엇으로 이동하나요', + 'roadtrip.limit.vehicleAny': '둘 다', + 'roadtrip.limit.vehicleCombustion': '연료', + 'roadtrip.limit.vehicleElectric': '전기', 'roadtrip.refuel.find': '주유소 찾기', + 'roadtrip.refuel.again': '다시 시도', 'roadtrip.refuel.looking': '경로를 따라 찾는 중…', 'roadtrip.refuel.spare': '{distance} 여유', 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 007ab2e76..9fc025ba5 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} vermeden', 'roadtrip.avoid.missed': '{classes} onvermijdelijk', 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', - 'roadtrip.refuel.dry': 'De tank raakt leeg op dit traject, na {distance}', + 'roadtrip.refuel.dry': 'Hier is de tank leeg', + 'roadtrip.refuel.after': 'na {distance}', + 'roadtrip.limit.vehicleLabel': 'Wat je rijdt', + 'roadtrip.limit.vehicleAny': 'Beide', + 'roadtrip.limit.vehicleCombustion': 'Benzine', + 'roadtrip.limit.vehicleElectric': 'Elektrisch', 'roadtrip.refuel.find': 'Tankstation zoeken', + 'roadtrip.refuel.again': 'Opnieuw', 'roadtrip.refuel.looking': 'Zoeken langs de route…', 'roadtrip.refuel.spare': 'nog {distance}', 'roadtrip.refuel.add': '{name} als tankstop toevoegen', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 398b36ed2..f402ce6fd 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} omijane', 'roadtrip.avoid.missed': '{classes} nie do ominięcia', 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', - 'roadtrip.refuel.dry': 'Na tym odcinku kończy się paliwo, po {distance}', + 'roadtrip.refuel.dry': 'Tu kończy się paliwo', + 'roadtrip.refuel.after': 'po {distance}', + 'roadtrip.limit.vehicleLabel': 'Czym jeździsz', + 'roadtrip.limit.vehicleAny': 'Oba', + 'roadtrip.limit.vehicleCombustion': 'Paliwo', + 'roadtrip.limit.vehicleElectric': 'Elektryk', 'roadtrip.refuel.find': 'Znajdź paliwo', + 'roadtrip.refuel.again': 'Ponów', 'roadtrip.refuel.looking': 'Szukam wzdłuż trasy…', 'roadtrip.refuel.spare': 'zostaje {distance}', 'roadtrip.refuel.add': 'Dodaj {name} jako postój na paliwo', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index f57ca820d..12649b452 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обход', 'roadtrip.avoid.missed': '{classes} не обойти', 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', - 'roadtrip.refuel.dry': 'На этом участке кончится топливо, через {distance}', + 'roadtrip.refuel.dry': 'Здесь кончится топливо', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'На чём вы едете', + 'roadtrip.limit.vehicleAny': 'Оба', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Электро', 'roadtrip.refuel.find': 'Найти заправку', + 'roadtrip.refuel.again': 'Ещё раз', 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…', 'roadtrip.refuel.spare': 'останется {distance}', 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 8d48aa86e..4bc554425 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} undviks', 'roadtrip.avoid.missed': '{classes} går inte att undvika', 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', - 'roadtrip.refuel.dry': 'Tanken tar slut på den här etappen, efter {distance}', + 'roadtrip.refuel.dry': 'Här tar tanken slut', + 'roadtrip.refuel.after': 'efter {distance}', + 'roadtrip.limit.vehicleLabel': 'Vad du kör', + 'roadtrip.limit.vehicleAny': 'Båda', + 'roadtrip.limit.vehicleCombustion': 'Bensin', + 'roadtrip.limit.vehicleElectric': 'El', 'roadtrip.refuel.find': 'Hitta bränsle', + 'roadtrip.refuel.again': 'Försök igen', 'roadtrip.refuel.looking': 'Söker längs rutten…', 'roadtrip.refuel.spare': '{distance} kvar', 'roadtrip.refuel.add': 'Lägg till {name} som tankstopp', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 235c83036..8508a5f58 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} kaçınılıyor', 'roadtrip.avoid.missed': '{classes} kaçınılmaz', 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', - 'roadtrip.refuel.dry': 'Bu etapta depo bitiyor, {distance} sonra', + 'roadtrip.refuel.dry': 'Depo burada bitiyor', + 'roadtrip.refuel.after': '{distance} sonra', + 'roadtrip.limit.vehicleLabel': 'Ne kullanıyorsun', + 'roadtrip.limit.vehicleAny': 'İkisi de', + 'roadtrip.limit.vehicleCombustion': 'Benzin', + 'roadtrip.limit.vehicleElectric': 'Elektrik', 'roadtrip.refuel.find': 'Yakıt bul', + 'roadtrip.refuel.again': 'Yeniden dene', 'roadtrip.refuel.looking': 'Rota boyunca aranıyor…', 'roadtrip.refuel.spare': '{distance} kalır', 'roadtrip.refuel.add': '{name} yakıt molası olarak eklensin', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 9f4fa7552..6707817bf 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '{count} в обхід', 'roadtrip.avoid.missed': '{classes} не обійти', 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', - 'roadtrip.refuel.dry': 'На цій ділянці закінчиться пальне, через {distance}', + 'roadtrip.refuel.dry': 'Тут закінчиться пальне', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'Чим ви їдете', + 'roadtrip.limit.vehicleAny': 'Обидва', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Електро', 'roadtrip.refuel.find': 'Знайти заправку', + 'roadtrip.refuel.again': 'Ще раз', 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…', 'roadtrip.refuel.spare': 'лишиться {distance}', 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 3bc408bce..1bec30741 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': 'tránh {count}', 'roadtrip.avoid.missed': '{classes} không tránh được', 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', - 'roadtrip.refuel.dry': 'Hết xăng ở chặng này, sau {distance}', + 'roadtrip.refuel.dry': 'Hết xăng ở đây', + 'roadtrip.refuel.after': 'sau {distance}', + 'roadtrip.limit.vehicleLabel': 'Bạn lái xe gì', + 'roadtrip.limit.vehicleAny': 'Cả hai', + 'roadtrip.limit.vehicleCombustion': 'Xăng', + 'roadtrip.limit.vehicleElectric': 'Điện', 'roadtrip.refuel.find': 'Tìm trạm xăng', + 'roadtrip.refuel.again': 'Thử lại', 'roadtrip.refuel.looking': 'Đang tìm dọc tuyến đường…', 'roadtrip.refuel.spare': 'còn {distance}', 'roadtrip.refuel.add': 'Thêm {name} làm điểm đổ xăng', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 60c93ce28..8900e3656 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避開 {count} 項', 'roadtrip.avoid.missed': '無法避開{classes}', 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', - 'roadtrip.refuel.dry': '本段將耗盡油量,行駛 {distance} 後', + 'roadtrip.refuel.dry': '此處將耗盡油量', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '你開什麼車', + 'roadtrip.limit.vehicleAny': '兩者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '電動', 'roadtrip.refuel.find': '尋找加油站', + 'roadtrip.refuel.again': '重試', 'roadtrip.refuel.looking': '正在沿路線尋找…', 'roadtrip.refuel.spare': '剩餘 {distance}', 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 81012d2c6..9697bafac 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -38,8 +38,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.avoid.badge': '避开 {count} 项', 'roadtrip.avoid.missed': '无法避开{classes}', 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', - 'roadtrip.refuel.dry': '本段将耗尽油量,行驶 {distance} 后', + 'roadtrip.refuel.dry': '此处将耗尽油量', + 'roadtrip.refuel.after': '{distance} 后', + 'roadtrip.limit.vehicleLabel': '你开什么车', + 'roadtrip.limit.vehicleAny': '两者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '电动', 'roadtrip.refuel.find': '查找加油站', + 'roadtrip.refuel.again': '重试', 'roadtrip.refuel.looking': '正在沿路线查找…', 'roadtrip.refuel.spare': '剩余 {distance}', 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点', From aaf6559d5d5d1ea08517867fadd222faf7cc62b1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 22:35:11 +0200 Subject: [PATCH 18/29] feat(roadtrip): state a range as the car it comes from A range could only be typed as one number, and that is the number fewest travellers actually know. A tank size is on the filler flap, a consumption is in the manual, a battery capacity and a kWh per hundred are on the data sheet. All of them divide into the same figure, so the dialog now takes either and does the division itself. What the parts say wins over what was typed, because it is the more specific answer, and the range row goes read-only when they do so there is never a second answer to the same question. Of ABRP's list only battery wear is here. Plug type, reference speed, relative speed and drive style shape a consumption PREDICTION; this addon does not predict one, it divides a capacity by a consumption the traveller states, so those fields would add typing without moving a kilometre. Wear is different in kind: it shrinks the capacity itself, and on an older car by enough to decide whether the last leg of a day arrives. Storage stays metric throughout, the same rule the range itself follows. An imperial traveller types gallons, miles per gallon and kWh per 100 miles and gets them back unchanged; mpg is the inverse of litres per hundred rather than a scale factor, which a straight multiplication would have stored as a car four times as thirsty. The gauge under the picker is the argument that these two ways of stating a range are the same thing, so it answers while the figures are still being typed rather than after the field is left. It draws the tank the day it left the factory: the filled part is what a stop actually puts back in, the hatched tail is what age has taken off, and both are things the bare number cannot say. Also here, from the same round: a fill-up that stops short of full. Nobody charges to 100 % on the road, and counting every stop as a full tank overstated everything after it by that last fifth. Stops that refuel now carry a badge saying how far they fill. --- .../Roadtrip/RoadtripLimitsCard.test.tsx | 75 ++- .../Roadtrip/RoadtripLimitsCard.tsx | 508 ++++++++++++++---- .../components/Roadtrip/RoadtripSidebar.tsx | 47 +- .../components/Roadtrip/roadtripModel.test.ts | 43 ++ .../src/components/Roadtrip/roadtripModel.ts | 20 +- .../components/Roadtrip/useRoadtripRoutes.ts | 37 +- .../components/Roadtrip/vehicleRange.test.ts | 113 ++++ .../src/components/Roadtrip/vehicleRange.ts | 157 ++++++ client/src/types.ts | 33 ++ shared/src/i18n/ar/roadtrip.ts | 20 + shared/src/i18n/br/roadtrip.ts | 20 + shared/src/i18n/ca/roadtrip.ts | 20 + shared/src/i18n/cs/roadtrip.ts | 20 + shared/src/i18n/de/roadtrip.ts | 20 + shared/src/i18n/en/roadtrip.ts | 20 + shared/src/i18n/es/roadtrip.ts | 20 + shared/src/i18n/fr/roadtrip.ts | 20 + shared/src/i18n/gr/roadtrip.ts | 20 + shared/src/i18n/hu/roadtrip.ts | 20 + shared/src/i18n/id/roadtrip.ts | 20 + shared/src/i18n/it/roadtrip.ts | 20 + shared/src/i18n/ja/roadtrip.ts | 20 + shared/src/i18n/ko/roadtrip.ts | 20 + shared/src/i18n/nl/roadtrip.ts | 20 + shared/src/i18n/pl/roadtrip.ts | 20 + shared/src/i18n/ru/roadtrip.ts | 20 + shared/src/i18n/sv/roadtrip.ts | 20 + shared/src/i18n/tr/roadtrip.ts | 20 + shared/src/i18n/uk/roadtrip.ts | 20 + shared/src/i18n/vi/roadtrip.ts | 20 + shared/src/i18n/zh-TW/roadtrip.ts | 20 + shared/src/i18n/zh/roadtrip.ts | 20 + 32 files changed, 1381 insertions(+), 112 deletions(-) create mode 100644 client/src/components/Roadtrip/vehicleRange.test.ts create mode 100644 client/src/components/Roadtrip/vehicleRange.ts diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx index 8ab93f52a..a6b396379 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.test.tsx @@ -5,7 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore' import RoadtripLimitsCard from './RoadtripLimitsCard' /** - * FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning. + * FE-ROADTRIP-LIMITS-001..012 — the numbers that decide every warning. * * The one that matters is when a number is written. These fields had no local * state, so every keystroke was a settings PUT: typing "180" sent three, and @@ -18,7 +18,7 @@ function open(onSave?: (key: string, value: number) => void) { fireEvent.click(screen.getByRole('button')) } -/** The three inputs, in the order the dialog lists them. */ +/** Every number field, in the order the dialog lists them. */ const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[] beforeEach(() => { @@ -127,4 +127,75 @@ describe('RoadtripLimitsCard', () => { // handler to call, and must not assume one. expect(() => fireEvent.blur(leg)).not.toThrow() }) + + it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => { + useSettingsStore.setState({ + settings: { roadtrip_vehicle: 'electric', distance_unit: 'metric' } as never, + }) + open(vi.fn()) + + // Leg, day, battery, consumption, wear, range, fill. The three in the middle + // do not exist until there is a kind of car to have them. + expect(inputs()).toHaveLength(7) + expect(screen.getByText('kWh')).toBeInTheDocument() + expect(screen.getByText('kWh/100 km')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-LIMITS-010: a complete pair takes the range field over', () => { + useSettingsStore.setState({ + settings: { + roadtrip_vehicle: 'electric', + roadtrip_battery_kwh: 58, + roadtrip_kwh_per_100: 16, + // Deliberately different from what the figures work out to: the point of + // the row going read-only is that there is no longer a second answer. + roadtrip_range_km: 500, + distance_unit: 'metric', + } as never, + }) + open(vi.fn()) + + expect(screen.getByTestId('limit-derived')).toHaveTextContent('363') + // Leg, day, battery, consumption, wear, fill — the range input is gone. + expect(inputs()).toHaveLength(6) + }) + + it('FE-ROADTRIP-LIMITS-011: gallons typed by an imperial reader are stored as litres', () => { + useSettingsStore.setState({ + settings: { roadtrip_vehicle: 'combustion', distance_unit: 'imperial' } as never, + }) + const onSave = vi.fn() + open(onSave) + + // Leg, day, tank, consumption, range, fill. + const tank = inputs()[2] + fireEvent.change(tank, { target: { value: '15' } }) + fireEvent.blur(tank) + + // 15 US gallons, not 15 litres. Storing the number as typed would have made + // every fuel warning fire at a quarter of the right distance. + expect(onSave).toHaveBeenCalledWith('roadtrip_tank_litres', 56.78) + }) + it('FE-ROADTRIP-LIMITS-012: the range answers while the figure is still being typed', () => { + useSettingsStore.setState({ + settings: { + roadtrip_vehicle: 'electric', + roadtrip_battery_kwh: 58, + roadtrip_kwh_per_100: 16, + distance_unit: 'metric', + } as never, + }) + const onSave = vi.fn() + open(onSave) + + // Leg, day, battery, consumption, wear, fill — the range row is read-only here. + const consumption = inputs()[3] + fireEvent.change(consumption, { target: { value: '20' } }) + + // 58 kWh at 20 per hundred is 290 km, shown before anything is written: the gauge + // is the argument that these figures ARE the range, and it only makes it if it + // answers now rather than after the field is left. + expect(screen.getAllByText('290').length).toBeGreaterThan(0) + expect(onSave).not.toHaveBeenCalled() + }) }) diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index 82baaf3b4..a6215d717 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -1,31 +1,38 @@ import React, { useState } from 'react' -import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react' +import { + Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, + Car, Zap, BatteryCharging, BatteryFull, BatteryWarning, Gauge, Route, Sparkles, +} from 'lucide-react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' -import { convertDistance, formatDistance } from '../../utils/units' -import { formatDurationShort, parseAvoid, serializeAvoid, AVOIDABLE } from './roadtripModel' +import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units' +import { formatDurationShort, parseAvoid, serializeAvoid, type VehicleKind } from './roadtripModel' +import { effectiveRangeKm, rangeFromSpec, showSpec, storeSpec, specUnit, type SpecKey, type VehicleSpec } from './vehicleRange' import { valhallaAvailable } from '../Map/valhallaRoute' import ToggleSwitch from '../Settings/ToggleSwitch' import type { DistanceUnit, RouteAvoidClass } from '../../types' import { FS } from './typeScale' /** - * The three numbers that decide when the rail speaks up about the driving. + * Everything that decides when the rail speaks up about the driving. * * Behind a button rather than laid out in the column, because this is set once and then - * read never: a permanent card costs three rows of a narrow sidebar for something most + * read never: a permanent card costs rows of a narrow sidebar for something most * travellers touch on the first day of planning and leave alone after. The button carries - * the current answer, so what is set stays visible without the form being. + * the current answers, so what is set stays visible without the form being. * - * All three are personal rather than instance configuration: how long somebody is willing - * to sit behind a wheel, and how far their car goes on a tank, belong to the traveller. - * They are plain per-user settings, which is why this needs no migration. + * All of it is personal rather than instance configuration: how long somebody is willing + * to sit behind a wheel, and what their car is, belong to the traveller. They are plain + * per-user settings, which is why this needs no migration. * - * Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no - * idea what car this is, and neither has TREK, so the range is a number the traveller - * types. Furkot does exactly the same, for the same reason. + * The dialog has two halves because the settings are two kinds. The left is the trip: how + * long a day may be and what the road may not be. The right is the car, and it is the + * half that earns the width — a range can be typed as one number or worked out from what + * the vehicle is made of, and the gauge shows the second turning into the first as it is + * typed. Nothing here asks the router about a vehicle; OSM has no idea what car this is, + * and neither has TREK. */ /** Empty means no limit, and so does zero — both are stored as 0 and read as off. */ @@ -34,12 +41,41 @@ function parseLimit(raw: string): number { return Number.isFinite(n) && n > 0 ? n : 0 } -function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: { +/** + * One number in the form, or one number the form worked out. + * + * `derived` turns the row read-only and states where the figure came from. The row keeps + * its shape either way, because a range that switched between an input and a line of text + * would move every row under it the moment a consumption was typed. + */ +function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, wide, onDraft, onChange }: { icon: typeof Clock label: string suffix: string value: number | undefined placeholder: string + step?: number + derived?: string + /** + * Room for a unit longer than a symbol, set per section rather than per row. + * + * "kWh/100 km" and "L/100 km" are units people actually quote, and a column sized for + * "min" wrapped them onto a second line, which made one row of the vehicle section + * taller than the rest. Sizing it from the string would have done the same thing + * sideways: only the consumption row would widen, and the section's right edge would + * come out ragged. + */ + wide?: boolean + /** + * What is in the field right now, on every keystroke, saved or not. + * + * Separate from `onChange` because the two answer different questions. `onChange` is + * "this is the new setting", which must stay rare — it is an HTTP write. This one is + * "this is what the traveller is looking at", which the gauge needs on every keystroke + * or it would sit still until the field is left and the promise it makes, that these + * figures are the range, would only pay out after the fact. + */ + onDraft?: (next: number) => void onChange: (next: number) => void }): React.ReactElement { // Typed here, saved on blur or Enter. Writing on every keystroke sent one @@ -60,21 +96,37 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: { ) } @@ -104,13 +156,160 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: { ) } +/** The name of one half of the form. */ +function SectionTitle({ icon: Icon, children }: { + icon: typeof Clock + children: React.ReactNode +}): React.ReactElement { + return ( +

+ + {children} +

+ ) +} + +/** + * The range, drawn as the thing it describes. + * + * The bar is the tank the day it left the factory, left to right, and it carries the two + * figures a bare number cannot say on its own: + * + * - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the + * road, and a traveller who set 80 % is looking at four fifths of a bar, which is the + * honest picture of what the next leg has. + * - The hatched tail is what age has taken off an electric car's battery. It is already + * out of the number above, so without the tail that figure looks like an unexplained + * disagreement with the car's own data sheet. + * + * Widths animate because this sits directly under the fields that feed it: typing a + * consumption and watching the bar move is what makes the two ways of stating a range + * visibly the same thing. + */ +function RangeGauge({ rangeKm, fillPercent, wearPercent, unit, electric, t }: { + rangeKm: number | null + fillPercent: number | null + wearPercent: number | null + unit: DistanceUnit + electric: boolean + t: (key: string, params?: Record) => string +}): React.ReactElement { + // Capped where the arithmetic caps it, so the picture cannot claim a battery is gone. + const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0 + const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100 + const usable = 100 - wear + const filled = rangeKm ? (usable * fill) / 100 : 0 + const rest = 100 - wear - filled + // Split rather than formatted whole, so the unit can sit smaller beside the figure. + // The rounding is formatDistance's own, because the badge on the trigger uses that and + // the two must not disagree by a tenth. + const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null + const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)' + + return ( +
+
+ + {electric ? : } + + + + + {shown ?? '—'} + + {shown !== null ? ( + + {getDistanceUnitLabel(unit)} + + ) : null} + + + {shown === null + ? t('roadtrip.limit.rangeEmpty') + : t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')} + + +
+ + {/* The quarter marks are painted over the whole bar rather than drawn into each + segment, so they stay put while the segments under them move. They are the + card's own colour, which makes them read as cut-outs: visible across the filled + part, invisible over the empty track, where there is nothing to divide. */} +
+
+
+
+
+
+
+
+ +
+ {shown === null ? ( + {t('roadtrip.limit.rangeEmptyHint')} + ) : ( + <> + + {fill < 100 + ? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) }) + : t('roadtrip.limit.fullNote')} + + {/* The swatch is what ties the sentence to the tail of the bar. Without it the + hatching is an unexplained stripe and the sentence an unexplained loss. */} + {wear > 0 ? ( + + + {t('roadtrip.limit.wearNote', { percent: wear })} + + ) : null} + + )} +
+
+ ) +} + /** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */ -const VEHICLES: { key: 'combustion' | 'electric' | null; labelKey: string; Icon: typeof Car }[] = [ +const VEHICLES: { key: VehicleKind | null; labelKey: string; Icon: typeof Car }[] = [ { key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car }, { key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel }, { key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap }, ] +/** + * What each kind of car is made of, and where each figure is kept. + * + * Two rows for petrol and three for electric, because that is the difference that + * matters: a tank does not lose capacity to the years and a battery does. Everything else + * ABRP asks for — plug type, reference speed, relative speed, drive style — shapes a + * consumption PREDICTION. This dialog does not predict one; it divides a capacity by a + * consumption the traveller states, so those fields would add typing without moving a + * single kilometre of the answer. + */ +const SPEC_ROWS: Record = { + combustion: [ + { key: 'tankLitres', setting: 'roadtrip_tank_litres', Icon: Fuel, labelKey: 'roadtrip.limit.tankLabel', step: 1 }, + { key: 'litresPer100', setting: 'roadtrip_litres_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 }, + ], + electric: [ + { key: 'batteryKwh', setting: 'roadtrip_battery_kwh', Icon: BatteryFull, labelKey: 'roadtrip.limit.batteryLabel', step: 1 }, + { key: 'kwhPer100', setting: 'roadtrip_kwh_per_100', Icon: Gauge, labelKey: 'roadtrip.limit.useLabel', step: 0.1 }, + { key: 'degradationPercent', setting: 'roadtrip_battery_degradation', Icon: BatteryWarning, labelKey: 'roadtrip.limit.wearLabel', step: 1 }, + ], +} + export default function RoadtripLimitsCard({ onSave }: { /** * Persists one setting. Absent leaves the dialog read-only. @@ -124,6 +323,7 @@ export default function RoadtripLimitsCard({ onSave }: { const settings = useSettingsStore(s => s.settings) const [open, setOpen] = useState(false) const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric' + const imperial = distanceUnit === 'imperial' const legMinutes = settings.roadtrip_leg_minutes const dayMinutes = settings.roadtrip_day_minutes @@ -132,6 +332,42 @@ export default function RoadtripLimitsCard({ onSave }: { // Parsed rather than trusted: a per-user setting gets no server-side validation, and // an unknown word here would become a costing option the router does not have. const vehicle = settings.roadtrip_vehicle ?? '' + const vehicleKind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null + const saved: VehicleSpec = { + tankLitres: settings.roadtrip_tank_litres, + litresPer100: settings.roadtrip_litres_per_100, + batteryKwh: settings.roadtrip_battery_kwh, + kwhPer100: settings.roadtrip_kwh_per_100, + degradationPercent: settings.roadtrip_battery_degradation, + } + /** + * The one field being typed in, ahead of the write that will save it. + * + * One slot rather than a copy of the form, because only one field can be typed in at a + * time, and a second copy of the settings is exactly the hand-mirrored state this + * codebase refuses everywhere else. It exists so the gauge answers while the traveller + * is still typing: the figures below it are only convincingly "the range" if changing + * one moves the bar, and a settings PUT per keystroke is not on offer. + * + * The value arrives in whatever unit the field is labelled with, so it goes back + * through the same conversion the save would have used. + */ + const [preview, setPreview] = useState<{ key: SpecKey | 'range' | 'fill'; value: number } | null>(null) + const spec: VehicleSpec = preview && preview.key !== 'range' && preview.key !== 'fill' + ? { ...saved, [preview.key]: storeSpec(preview.key, preview.value, imperial) } + : saved + const typedKm = preview?.key === 'range' + ? (imperial ? preview.value / 0.621371 : preview.value) + : rangeKm + const shownFill = preview?.key === 'fill' ? preview.value : settings.roadtrip_fill_percent ?? null + + // What the parts say, and what the trip will actually plan with. The second is the + // first when there is one, which is the rule effectiveRangeKm states once for + // everybody who needs it. + const computedKm = rangeFromSpec(vehicleKind, spec) + const planningKm = effectiveRangeKm(vehicleKind, spec, typedKm) + const avoiding = parseAvoid(settings.roadtrip_avoid) // No second engine, no avoidance. An instance pointed at its own OSRM has one, and its // car profile is built without excludable classes on every public host. @@ -149,22 +385,30 @@ export default function RoadtripLimitsCard({ onSave }: { // Kilometres in storage, the traveller's own unit on screen. Without the round trip an // imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a - // third too early for ever after. - const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined + // third too early for ever after. The same rule runs through the vehicle's own figures, + // where showSpec and storeSpec do it per field. + const rangeShown = planningKm ? Math.round(convertDistance(planningKm, distanceUnit)) : undefined const setRange = (shown: number) => { - const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown + const km = imperial ? shown / 0.621371 : shown onSave?.('roadtrip_range_km', Math.round(km)) } // What is set, on the button itself, so the form does not have to be open to read it. - // Each one keeps its own icon, because three numbers in a row say nothing about which - // is which: a clock, a calendar and a pump do. Distances go through formatDistance, so - // an imperial traveller reads miles here and types miles in the dialog, while what is + // Each one keeps its own icon, because numbers in a row say nothing about which is + // which: a clock, a calendar and a pump do. Distances go through formatDistance, so an + // imperial traveller reads miles here and types miles in the dialog, while what is // stored stays kilometres either way. const badges = [ legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null, dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null, - rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null, + // The planning range, not the typed one: when the vehicle's figures win, the badge + // has to say what the warnings will actually use. + planningKm ? { key: 'range', Icon: vehicleKind === 'electric' ? Zap : Fuel, text: formatDistance(planningKm, distanceUnit) } : null, + // Only worth a badge when it is NOT a full tank, which is the case that changes the + // arithmetic. "100 %" on the trigger would be a badge for the default. + settings.roadtrip_fill_percent && settings.roadtrip_fill_percent < 100 + ? { key: 'fill', Icon: BatteryCharging, text: `${settings.roadtrip_fill_percent} %` } + : null, // The avoidance rides here too, and as ONE badge rather than three: it is a single // decision, the three icons already say which parts of it are on, and a fourth, // fifth and sixth badge would wrap the row onto a second line in a narrow rail. @@ -184,7 +428,7 @@ export default function RoadtripLimitsCard({ onSave }: { onClick={() => setOpen(true)} className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" > - {/* No icon tile on the trigger: the three badges below already carry a clock, a + {/* No icon tile on the trigger: the badges below already carry a clock, a calendar and a pump, and a fourth mark in front of the title only ate the width they need to sit on one line. */} @@ -220,72 +464,45 @@ export default function RoadtripLimitsCard({ onSave }: { {open ? ( - setOpen(false)} size="sm" title={ + { setOpen(false); setPreview(null) }} size="2xl" title={ {t('roadtrip.limit.title')} }> -
- onSave?.('roadtrip_leg_minutes', v)} - /> - onSave?.('roadtrip_day_minutes', v)} - /> - {/* Above the range, because it decides what the range even means: with no - vehicle named, a petrol station and a charger both fill the tank, which - is arithmetic that is wrong for everybody who drives just one of them. */} -
+ + ) +} diff --git a/client/src/components/Roadtrip/StopKindPicker.tsx b/client/src/components/Roadtrip/StopKindPicker.tsx index 11dfbf4c2..e037f2350 100644 --- a/client/src/components/Roadtrip/StopKindPicker.tsx +++ b/client/src/components/Roadtrip/StopKindPicker.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import React from 'react' import { MapPin } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' +import AnchoredPopover from './AnchoredPopover' import { STOP_KINDS } from './stopKinds' import type { RoadtripStopType } from '@trek/shared' @@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: { onClose: () => void }): React.ReactElement | null { const { t } = useTranslation() - const ref = useRef(null) - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) - // Measured after paint, before the browser shows it: reading the size first and then - // placing it is what stops the panel appearing at 0,0 for one frame. - useLayoutEffect(() => { - if (!anchor || !ref.current) return - const a = anchor.getBoundingClientRect() - const p = ref.current.getBoundingClientRect() - const gap = 8 - let top = a.bottom + gap - // Flips above when there is no room below, which is most of the rail on a short - // window: a panel clipped by the viewport is one that cannot be used at all. - if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap) - // Centred on the column, not on the number. The number sits hard against the left - // edge of the rail, so a panel centred on it hangs half off the sidebar and points at - // the map. Vertically it still follows the row it belongs to. - const column = anchor.closest('section')?.getBoundingClientRect() ?? a - const left = Math.min( - Math.max(8, column.left + column.width / 2 - p.width / 2), - window.innerWidth - p.width - 8, - ) - setPos({ top, left }) - }, [anchor]) - - useEffect(() => { - if (!anchor) return - const onDown = (e: MouseEvent) => { - if (ref.current?.contains(e.target as Node)) return - if (anchor.contains(e.target as Node)) return - onClose() - } - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) - // Any scroll moves the anchor out from under the panel, and following it would mean - // measuring on every frame for a menu that is open for two seconds. - window.addEventListener('scroll', onClose, true) - return () => { - document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) - window.removeEventListener('scroll', onClose, true) - } - }, [anchor, onClose]) - - if (!anchor) return null - - return createPortal( -
+ return ( +
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => { const on = current === key @@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: { {t('roadtrip.stop.backToDestination')} ) : null} -
, - document.body, +
) } diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 3cebde8ea..1dc6b7a30 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -768,4 +768,46 @@ describe('deriveDriveWarnings — filling only part way', () => { expect(out.warnings).toEqual([]) expect(out.emptyAt).toEqual([]) }) + + it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => { + // The motorway charger tops up to 60 %; the traveller's own default is 80. Reading + // the default here would promise 100 km the car does not have. + const out = deriveDriveWarnings( + [drive(60, 100), drive(60, 450)], + [false, true, false], + { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, + 0, + [null, 60, null], + ) + // 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than + // the 400 the traveller's own figure would have given. + expect(out.emptyAt).toHaveLength(1) + expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 }) + }) + + it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => { + // Same day, same stop, nothing said about it: the default has to keep applying, or + // adding the field would have quietly changed every trip that never used it. + const out = deriveDriveWarnings( + [drive(60, 100), drive(60, 450)], + [false, true, false], + { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, + 0, + [null, null, null], + ) + expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 }) + }) + + it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => { + // The charger at the hotel: the car stands there all night, so this one goes to 100 + // even on a trip whose default stops at 80. + const out = deriveDriveWarnings( + [drive(60, 100), drive(60, 450)], + [false, true, false], + { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, + 0, + [null, 100, null], + ) + expect(out.emptyAt).toEqual([]) + }) }) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index fd8ccfc78..b9d408dd4 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -432,6 +432,18 @@ export function deriveDriveWarnings( limits: DriveLimits, /** Kilometres already on the tank when the day starts; null when that is unknown. */ carryKm: number | null, + /** + * Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`. + * + * A property of the stop rather than of the traveller: a motorway rapid charger is + * worth about 80 % because the last fifth costs as long again, while the one at the + * hotel is worth all of it because the car stands there all night. One figure for the + * whole trip cannot say both, and the difference between them is a leg. + * + * Last and optional so the four-argument call, which is every caller that has no + * per-stop opinion, keeps meaning exactly what it did. + */ + fillAt: readonly (number | null | undefined)[] = [], ): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } { const warnings: ScheduleWarning[] = [] const emptyAt: DryPoint[] = [] @@ -443,15 +455,22 @@ export function deriveDriveWarnings( // walk the driving legs only, which is why this is counted here rather than derived. let drivenMeters = 0 - // What is left on the clock straight after a stop. A tank filled to 80 % has already - // used a fifth of its range before the car moves, which is the honest way to say it in - // a budget that counts upwards. - const usedAfterFilling = limits.rangeKm && limits.fillPercent - ? limits.rangeKm * (1 - limits.fillPercent / 100) - : 0 + /** + * What is already on the clock the moment the car pulls away from stop `i`. + * + * A tank filled to 80 % has used a fifth of its range before it moves, which is how a + * budget that counts upwards says "not full". Zero, absent and 100 all mean it filled + * right up, so all three land on nothing used. + */ + const usedAfterFilling = (i: number): number => { + const percent = fillAt[i] ?? limits.fillPercent + return limits.rangeKm && percent && percent > 0 && percent < 100 + ? limits.rangeKm * (1 - percent / 100) + : 0 + } for (let i = 0; i < legs.length; i++) { - if (refuelsAt[i]) budget = usedAfterFilling + if (refuelsAt[i]) budget = usedAfterFilling(i) const leg = legs[i] // The stop this leg arrives at. Both findings are about what is true on arrival. const at = i + 1 @@ -502,7 +521,7 @@ export function deriveDriveWarnings( } // The last stop of the day counts too: filling up on arrival is what makes the next // morning start with a full tank. - if (refuelsAt[legs.length]) budget = usedAfterFilling + if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length) const minutes = Math.round(totalSeconds / 60) const day = limits.dayMinutes && minutes > limits.dayMinutes diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 69c1e554e..75bb78d04 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -6,7 +6,7 @@ import { type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel' import { projectOntoRoute, pointAtMeters} from './corridor' import { useSettingsStore } from '../../store/settingsStore' -import { effectiveRangeKm } from './vehicleRange' +import { useVehicleRange } from './useVehicleRange' import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' import { spurFor } from './accessSpur' import type { RoadtripVia } from '@trek/shared' @@ -27,6 +27,17 @@ export interface RoadtripStop { incomingLegMode: string | null /** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */ stopType: string | null + /** + * How full THIS stop fills up, 1-100, or null to follow the traveller's own setting. + * + * A property of the stop: the motorway rapid charger is worth 80 % because the last + * fifth costs as long again, the one at the hotel is worth all of it because the car + * stands there all night. + * + * Optional, like `offRoadMeters`: absent and null say the same thing, and a stop built + * anywhere that has no opinion about filling up should not have to say so. + */ + fillPercent?: number | null /** * How far the router had to go to find a road, in metres, once this stop has routed. * @@ -228,6 +239,7 @@ const asStop = (a: Assignment): RoadtripStop | null => { legMode: a.leg_transport_mode ?? null, incomingLegMode: a.incoming_leg_transport_mode ?? null, stopType: p.stop_type ?? null, + fillPercent: typeof p.fill_percent === 'number' ? p.fill_percent : null, } } @@ -275,7 +287,6 @@ export function useRoadtripRoutes( const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes) const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes) - const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km) const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent) /** * Road classes to weight away, as the settings row stores them: a comma list. @@ -285,37 +296,11 @@ export function useRoadtripRoutes( * any value — and an unknown word here would become a costing option the router does * not have. */ - const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid) const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) - // Validated rather than trusted, like the avoidance above: a per-user setting gets no - // server-side check, and an unknown word here would silently stop both kinds counting. - const vehicleKind: VehicleKind | null = - vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null - /** - * What the vehicle is made of, when the traveller filled that in instead of a range. - * - * Read as five primitives rather than as one object so the memo below compares numbers. - * An object rebuilt each render would give `limits` a new identity every time, and - * `limits` is what the route effect watches — every keystroke anywhere in the app would - * recalculate every day's driving. - */ - const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres) - const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100) - const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh) - const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100) - const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation) - // The parts win over the typed number when they add up, because they are the more - // specific answer; the dialog shows the result, so it is never a surprise. Zero and - // absent both mean "no limit", which is why the falsy fold happens here once. - const planningRangeKm = useMemo( - () => effectiveRangeKm( - vehicleKind, - { tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent }, - rangeKm, - ) || null, - [vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm], - ) + // What the car is and how far it goes on one fill, assembled in one place because the + // rail needs the same answer to say what a given fill buys at a given stop. + const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange() const avoidKey = avoid.join(',') // Zero and absent both mean "no limit": zero is a legal thing to type and says the // same thing, so it is folded here rather than guarded at every reading. @@ -587,6 +572,9 @@ export function useRoadtripRoutes( stops.map(s => refuelsRange(s.stopType, vehicleKind)), limits, carryKm, + // Same length and same order, so a stop that says how far it fills is read + // against itself rather than against the traveller's default. + stops.map(s => s.fillPercent), ) carryKm = drive.carryKm // Where each tank runs dry, as a place rather than a distance. diff --git a/client/src/components/Roadtrip/useVehicleRange.ts b/client/src/components/Roadtrip/useVehicleRange.ts new file mode 100644 index 000000000..742a41409 --- /dev/null +++ b/client/src/components/Roadtrip/useVehicleRange.ts @@ -0,0 +1,46 @@ +import { useMemo } from 'react' +import { useSettingsStore } from '../../store/settingsStore' +import { effectiveRangeKm } from './vehicleRange' +import type { VehicleKind } from './roadtripModel' + +/** + * How far the traveller's car goes on one fill, and what kind of car it is. + * + * One place rather than two, because the answer is assembled from seven settings and two + * readers need it: the routes hook, which turns it into the range budget every warning is + * measured against, and the rail, which tells a traveller what a given fill buys at a + * given stop. Assembling it twice is the hand-mirrored state this codebase refuses + * everywhere else, and the two copies would drift the first time a field is added. + * + * Each setting is read as its own primitive so the memo compares numbers. An object + * rebuilt on every render would give the result a new identity every time, and what + * depends on it is the effect that recalculates every day's driving. + */ +export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } { + const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km) + // Validated rather than trusted: a per-user setting gets no server-side check, and an + // unknown word here would silently stop both kinds of stop counting as a fill-up. + const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle) + const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres) + const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100) + const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh) + const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100) + const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation) + + const vehicleKind: VehicleKind | null = + vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null + + // The parts win over the typed number when they add up, because they are the more + // specific answer; the settings dialog shows the result, so it is never a surprise. + // Zero and absent both mean "no limit", which is why the falsy fold happens here once. + const planningKm = useMemo( + () => effectiveRangeKm( + vehicleKind, + { tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent }, + rangeKm, + ) || null, + [vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm], + ) + + return useMemo(() => ({ vehicleKind, rangeKm: planningKm }), [vehicleKind, planningKm]) +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 8d0ce48b9..f8989dde7 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -265,6 +265,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, setRoadtripStopKind, + setRoadtripStopFill, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, @@ -549,6 +550,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { openAlternatives={routeAlternatives.open} onEditStay={can('place_edit', trip) ? setStayDraft : undefined} onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined} + onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined} onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined} viaCounts={roadtripViaCounts} trackNames={followTrack.namesByDay} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 5dc36c36e..e5f58c5fe 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -888,6 +888,22 @@ export function useTripPlanner() { } }, [tripId, tripActions, toast, t]) + /** + * How full THIS stop fills the tank, from the road trip rail. + * + * The same shape as the kind above and for the same reason: one field on one place, + * with the rail redrawing off the store the moment it lands. Null hands the stop back + * to whatever the traveller set as their own default, which is what every stop does + * until somebody has an opinion about one. + */ + const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => { + try { + await tripActions.updatePlace(tripId, placeId, { fill_percent: percent }) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } + }, [tripId, tripActions, toast, t]) + /** * Moves a stop within its day, from the road trip rail. * @@ -1882,6 +1898,7 @@ export function useTripPlanner() { placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId, stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, setRoadtripStopKind, + setRoadtripStopFill, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, refuel, askRefuel, acceptRefuel, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f2e9c6408..d9746f80a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4514,6 +4514,26 @@ function runMigrations(db: Database.Database): void { // Retention deletes by day, and the summary reads the newest days first. db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)'); }, + /** + * How full THIS stop fills the tank, 1 to 100 (#1797). + * + * Beside stop_type rather than in the traveller's settings, because it is a property + * of the stop and not of the person: a motorway rapid charger gets 80 % because the + * last fifth would cost as long again, while the one at the hotel gets 100 % because + * the car stands there all night. One figure for the whole trip cannot say both, and + * the difference between them is a leg. + * + * NULL means "whatever the traveller's own setting says", which is every row that + * exists today and every stop nobody has an opinion about. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; + if (!cols.some(c => c.name === 'fill_percent')) { + db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 155469b29..e745aed32 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -60,7 +60,7 @@ export class AssignmentsService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -96,7 +96,7 @@ export class AssignmentsService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index e48d76733..2c964e8ec 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -46,6 +46,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[] // The rail marks a fuel stop as one; without it here every stop would need // its own place request to find out what kind it is. stop_type: a.stop_type ?? null, + // Same reason: the rail resets a range budget here and has to know how far this + // stop fills, not how far the traveller's default one does. + fill_percent: a.fill_percent ?? null, category: a.category_id ? { id: a.category_id, name: a.category_name, diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 70479427a..8a471ab4a 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -91,7 +91,7 @@ export class DaysService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -171,7 +171,7 @@ export class DaysService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, + p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 03325847b..80b5cf666 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -180,25 +180,27 @@ export class PlacesMcp { google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'), stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'places', mode: 'write' }, }) async updatePlace( - { tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type }: { + { tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }: { tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number; address?: string; category_id?: number; price?: number; currency?: string; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string; image_url?: string | null; transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string; google_place_id?: string; google_ftid?: string; stop_type?: RoadtripStopType | null; + fill_percent?: number | null; }, ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); - const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type }); + const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; this.guards.safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); @@ -427,16 +429,18 @@ export class PlacesMcp { image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'), description: z.string().max(2000).optional(), stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'), + fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'places', mode: 'write' }, }) async bulkUpdatePlaces( - { tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type }: { + { tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent }: { tripId: number; placeIds: number[]; category_id?: number; price?: number; currency?: string; transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; place_time?: string; end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string; image_url?: string | null; description?: string; stop_type?: RoadtripStopType | null; + fill_percent?: number | null; }, ctx: McpContext, ) { @@ -444,7 +448,7 @@ export class PlacesMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied(); - const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type }; + const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent }; if (Object.values(fields).every(v => v === undefined)) { return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true }; } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 730c6342d..c9229212d 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -70,6 +70,8 @@ export interface PlaceCreateInput { google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */ stop_type?: string | null; + /** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */ + fill_percent?: number | null; transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[]; } @@ -82,6 +84,8 @@ export interface PlaceUpdateInput { google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string; /** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */ stop_type?: string | null; + /** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */ + fill_percent?: number | null; transport_mode?: string; route_color?: string | null; tags?: number[]; } @@ -229,15 +233,15 @@ export class PlacesService { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, - transport_mode, route_geometry, route_color, stop_type, tags = [], + transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [], } = body; const result = this.dbs.run(` INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode, - route_geometry, route_color, stop_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + route_geometry, route_color, stop_type, fill_percent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, // lat/lng/price/duration_minutes use an explicit undefined check, not `||`: // 0 is a legitimate value for all four (Null Island, a free entry, a @@ -253,6 +257,9 @@ export class PlacesService { place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null, google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', route_geometry || null, route_color || null, stop_type || null, + // `?? null` rather than `|| null`, the same reason lat/lng have it: the column is a + // percentage and the falsy check would be a silent floor. + fill_percent ?? null, ); const placeId = result.lastInsertRowid; @@ -317,7 +324,7 @@ export class PlacesService { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, - transport_mode, route_color, stop_type, tags, + transport_mode, route_color, stop_type, fill_percent, tags, } = body; this.dbs.run(` @@ -343,6 +350,7 @@ export class PlacesService { transport_mode = COALESCE(?, transport_mode), route_color = ?, stop_type = ?, + fill_percent = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, @@ -372,6 +380,8 @@ export class PlacesService { route_color !== undefined ? route_color : existingPlace.route_color, // Same shape: an explicit null is how a fuel stop becomes an ordinary place again. stop_type !== undefined ? stop_type : existingPlace.stop_type, + // And how a stop that had its own fill amount goes back to following the setting. + fill_percent !== undefined ? fill_percent : existingPlace.fill_percent, placeId, ); diff --git a/server/src/types.ts b/server/src/types.ts index cbb56802c..9ed944821 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -80,6 +80,8 @@ export interface Place { transport_mode?: string; /** What kind of stop this is on a drive (#1797); null for an ordinary place. */ stop_type?: string | null; + /** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */ + fill_percent?: number | null; created_at?: string; updated_at?: string; } @@ -348,6 +350,8 @@ export interface AssignmentRow extends DayAssignment { website: string | null; phone: string | null; stop_type: string | null; + /** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */ + fill_percent: number | null; category_name: string | null; category_color: string | null; category_icon: string | null; diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 56b4f3b74..339d781c5 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -30,7 +30,7 @@ const { db } = vi.hoisted(() => { description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT, transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, - stop_type TEXT);`); + stop_type TEXT, fill_percent INTEGER);`); tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT, assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT, diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index 99fe187e9..6b2083d9a 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -33,7 +33,7 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, transport_mode TEXT, - google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, stop_type TEXT);`); + google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, stop_type TEXT, fill_percent INTEGER);`); tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, assignment_time TEXT, assignment_end_time TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 20dabf0fd..a00ab161f 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -28,7 +28,7 @@ const { db } = vi.hoisted(() => { description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, transport_mode TEXT, - route_geometry TEXT, route_color TEXT, stop_type TEXT, + route_geometry TEXT, route_color TEXT, stop_type TEXT, fill_percent INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`); tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index d9351eb7c..5ae8009e4 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT, title TEXT, notes TEXT);`); tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, - lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT);`); + lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT, fill_percent INTEGER);`); tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index e938bee95..61052d6f3 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} خلال {time}', 'roadtrip.day.stopCount': '{count} محطات', 'roadtrip.stop.stayShort': 'مدة', + 'roadtrip.stop.fillTitle': 'يملأ حتى', + 'roadtrip.stop.fillGives': 'نحو {distance} من هنا', + 'roadtrip.stop.fillNoRange': 'أدخل المدى لترى ما الذي يمنحه ذلك.', + 'roadtrip.stop.fillDefault': 'استخدام الإعداد الافتراضي الخاص بي', + 'roadtrip.stop.fillSet': 'حدد مقدار التعبئة في هذه المحطة', 'roadtrip.leg.pending': 'لا يوجد مسار', 'roadtrip.stop.addTitle': 'إضافة كمحطة توقف', 'roadtrip.stop.kind': 'نوع المحطة', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 9f2070960..34645d09f 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} em {time}', 'roadtrip.day.stopCount': '{count} paradas', 'roadtrip.stop.stayShort': 'Parada', + 'roadtrip.stop.fillTitle': 'Abastece até', + 'roadtrip.stop.fillGives': 'cerca de {distance} a partir daqui', + 'roadtrip.stop.fillNoRange': 'Informe uma autonomia para ver o que isso rende.', + 'roadtrip.stop.fillDefault': 'Usar meu padrão', + 'roadtrip.stop.fillSet': 'Definir quanto abastecer nesta parada', 'roadtrip.leg.pending': 'Sem rota', 'roadtrip.stop.addTitle': 'Adicionar como parada', 'roadtrip.stop.kind': 'Tipo de parada', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 0bb4aadb0..1dbd40f01 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} parades', 'roadtrip.stop.stayShort': 'Parada', + 'roadtrip.stop.fillTitle': 'Omple al', + 'roadtrip.stop.fillGives': 'uns {distance} des d’aquí', + 'roadtrip.stop.fillNoRange': 'Indica una autonomia per veure què dona.', + 'roadtrip.stop.fillDefault': 'Usa el meu valor per defecte', + 'roadtrip.stop.fillSet': 'Defineix quant s’omple en aquesta parada', 'roadtrip.leg.pending': 'Sense ruta', 'roadtrip.stop.addTitle': 'Afegeix com a parada', 'roadtrip.stop.kind': 'Tipus de parada', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 805f0e48a..7cec377c0 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} za {time}', 'roadtrip.day.stopCount': 'zastávek: {count}', 'roadtrip.stop.stayShort': 'Pobyt', + 'roadtrip.stop.fillTitle': 'Natankuje na', + 'roadtrip.stop.fillGives': 'asi {distance} odsud', + 'roadtrip.stop.fillNoRange': 'Zadej dojezd, ať je vidět, co to vynese.', + 'roadtrip.stop.fillDefault': 'Použít moji výchozí hodnotu', + 'roadtrip.stop.fillSet': 'Nastavit, na kolik se tady tankuje', 'roadtrip.leg.pending': 'Bez trasy', 'roadtrip.stop.addTitle': 'Přidat jako zastávku', 'roadtrip.stop.kind': 'Druh zastávky', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 57ce5e001..c965bb4f4 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} Stopps', 'roadtrip.stop.stayShort': 'Dauer', + 'roadtrip.stop.fillTitle': 'Tankt auf', + 'roadtrip.stop.fillGives': 'etwa {distance} ab hier', + 'roadtrip.stop.fillNoRange': 'Trag eine Reichweite ein, dann steht hier, was das bringt.', + 'roadtrip.stop.fillDefault': 'Meinen Standard nehmen', + 'roadtrip.stop.fillSet': 'Festlegen, wie voll hier getankt wird', 'roadtrip.leg.pending': 'Keine Route', 'roadtrip.stop.addTitle': 'Als Stopp hinzufügen', 'roadtrip.stop.kind': 'Art des Stopps', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 90b1c6cd6..69dfeae72 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} stops', 'roadtrip.stop.stayShort': 'Stay', + 'roadtrip.stop.fillTitle': 'Fills to', + 'roadtrip.stop.fillGives': 'about {distance} from here', + 'roadtrip.stop.fillNoRange': 'Set a range to see what that buys.', + 'roadtrip.stop.fillDefault': 'Use my default', + 'roadtrip.stop.fillSet': 'Set how full this stop fills', 'roadtrip.leg.pending': 'No route', 'roadtrip.stop.addTitle': 'Add as a stop', 'roadtrip.stop.kind': 'Kind of stop', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 51ca47b6f..deed3d6db 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} paradas', 'roadtrip.stop.stayShort': 'Parada', + 'roadtrip.stop.fillTitle': 'Reposta al', + 'roadtrip.stop.fillGives': 'unos {distance} desde aquí', + 'roadtrip.stop.fillNoRange': 'Indica una autonomía para ver lo que da.', + 'roadtrip.stop.fillDefault': 'Usar mi valor por defecto', + 'roadtrip.stop.fillSet': 'Definir cuánto se reposta en esta parada', 'roadtrip.leg.pending': 'Sin ruta', 'roadtrip.stop.addTitle': 'Añadir como parada', 'roadtrip.stop.kind': 'Tipo de parada', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 3e61f6303..3be9269cb 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} arrêts', 'roadtrip.stop.stayShort': 'Durée', + 'roadtrip.stop.fillTitle': 'Remplit à', + 'roadtrip.stop.fillGives': 'environ {distance} à partir d’ici', + 'roadtrip.stop.fillNoRange': 'Indiquez une autonomie pour voir ce que cela donne.', + 'roadtrip.stop.fillDefault': 'Utiliser ma valeur par défaut', + 'roadtrip.stop.fillSet': 'Définir le niveau de remplissage de cet arrêt', 'roadtrip.leg.pending': 'Pas d’itinéraire', 'roadtrip.stop.addTitle': 'Ajouter comme arrêt', 'roadtrip.stop.kind': 'Type d’arrêt', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 826161587..cb0b47aca 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} σε {time}', 'roadtrip.day.stopCount': '{count} στάσεις', 'roadtrip.stop.stayShort': 'Στάση', + 'roadtrip.stop.fillTitle': 'Γεμίζει στο', + 'roadtrip.stop.fillGives': 'περίπου {distance} από εδώ', + 'roadtrip.stop.fillNoRange': 'Δώσε μια αυτονομία για να φανεί τι αποδίδει.', + 'roadtrip.stop.fillDefault': 'Χρήση της προεπιλογής μου', + 'roadtrip.stop.fillSet': 'Όρισε πόσο γεμίζει αυτή η στάση', 'roadtrip.leg.pending': 'Χωρίς διαδρομή', 'roadtrip.stop.addTitle': 'Προσθήκη ως στάση', 'roadtrip.stop.kind': 'Είδος στάσης', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 87c9d9a87..97e53731d 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} {time} alatt', 'roadtrip.day.stopCount': '{count} megálló', 'roadtrip.stop.stayShort': 'Idő', + 'roadtrip.stop.fillTitle': 'Feltölt eddig', + 'roadtrip.stop.fillGives': 'innen körülbelül {distance}', + 'roadtrip.stop.fillNoRange': 'Add meg a hatótávot, hogy látszódjon, mennyit ér.', + 'roadtrip.stop.fillDefault': 'Az alapértelmezettet használom', + 'roadtrip.stop.fillSet': 'Beállítás: mennyire tölt fel ez a megálló', 'roadtrip.leg.pending': 'Nincs útvonal', 'roadtrip.stop.addTitle': 'Hozzáadás megállóként', 'roadtrip.stop.kind': 'Megálló típusa', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index e72111c13..bfb4069c6 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} dalam {time}', 'roadtrip.day.stopCount': '{count} perhentian', 'roadtrip.stop.stayShort': 'Singgah', + 'roadtrip.stop.fillTitle': 'Isi sampai', + 'roadtrip.stop.fillGives': 'sekitar {distance} dari sini', + 'roadtrip.stop.fillNoRange': 'Isi jarak jangkau untuk melihat hasilnya.', + 'roadtrip.stop.fillDefault': 'Pakai bawaan saya', + 'roadtrip.stop.fillSet': 'Atur seberapa penuh perhentian ini mengisi', 'roadtrip.leg.pending': 'Tidak ada rute', 'roadtrip.stop.addTitle': 'Tambahkan sebagai perhentian', 'roadtrip.stop.kind': 'Jenis perhentian', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 1a771bc22..54b6c004e 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} soste', 'roadtrip.stop.stayShort': 'Sosta', + 'roadtrip.stop.fillTitle': 'Riempie al', + 'roadtrip.stop.fillGives': 'circa {distance} da qui', + 'roadtrip.stop.fillNoRange': 'Inserisci un’autonomia per vedere quanto rende.', + 'roadtrip.stop.fillDefault': 'Usa il mio valore predefinito', + 'roadtrip.stop.fillSet': 'Imposta quanto si riempie in questa sosta', 'roadtrip.leg.pending': 'Nessun percorso', 'roadtrip.stop.addTitle': 'Aggiungi come sosta', 'roadtrip.stop.kind': 'Tipo di sosta', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index cebaa68d3..3bc49908e 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance}/{time}', 'roadtrip.day.stopCount': '{count} 箇所', 'roadtrip.stop.stayShort': '滞在', + 'roadtrip.stop.fillTitle': '補給する量', + 'roadtrip.stop.fillGives': 'ここから約 {distance}', + 'roadtrip.stop.fillNoRange': '航続距離を入れると、これで何 km 走れるか出ます。', + 'roadtrip.stop.fillDefault': '自分の既定値を使う', + 'roadtrip.stop.fillSet': 'この停車地でどこまで補給するか決める', 'roadtrip.leg.pending': 'ルートなし', 'roadtrip.stop.addTitle': '立ち寄り先として追加', 'roadtrip.stop.kind': '立ち寄りの種類', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 16f1a004b..e6e9f420b 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance}, {time}', 'roadtrip.day.stopCount': '{count}곳', 'roadtrip.stop.stayShort': '체류', + 'roadtrip.stop.fillTitle': '충전 목표', + 'roadtrip.stop.fillGives': '여기서 약 {distance}', + 'roadtrip.stop.fillNoRange': '주행거리를 입력하면 얼마나 가는지 표시됩니다.', + 'roadtrip.stop.fillDefault': '내 기본값 사용', + 'roadtrip.stop.fillSet': '이 정차지에서 얼마나 충전할지 설정', 'roadtrip.leg.pending': '경로 없음', 'roadtrip.stop.addTitle': '경유지로 추가', 'roadtrip.stop.kind': '경유지 종류', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index c5a3fe842..9a86638a6 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} stops', 'roadtrip.stop.stayShort': 'Duur', + 'roadtrip.stop.fillTitle': 'Vult tot', + 'roadtrip.stop.fillGives': 'ongeveer {distance} vanaf hier', + 'roadtrip.stop.fillNoRange': 'Vul een actieradius in om te zien wat dat oplevert.', + 'roadtrip.stop.fillDefault': 'Mijn standaard gebruiken', + 'roadtrip.stop.fillSet': 'Instellen hoe vol er hier wordt getankt', 'roadtrip.leg.pending': 'Geen route', 'roadtrip.stop.addTitle': 'Als stop toevoegen', 'roadtrip.stop.kind': 'Soort stop', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 786aa7256..ae8a5ca72 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} w {time}', 'roadtrip.day.stopCount': '{count} przystanków', 'roadtrip.stop.stayShort': 'Postój', + 'roadtrip.stop.fillTitle': 'Tankuje do', + 'roadtrip.stop.fillGives': 'około {distance} stąd', + 'roadtrip.stop.fillNoRange': 'Podaj zasięg, żeby zobaczyć, ile to daje.', + 'roadtrip.stop.fillDefault': 'Użyj mojej wartości domyślnej', + 'roadtrip.stop.fillSet': 'Ustaw, do ile tankować na tym postoju', 'roadtrip.leg.pending': 'Brak trasy', 'roadtrip.stop.addTitle': 'Dodaj jako przystanek', 'roadtrip.stop.kind': 'Rodzaj przystanku', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index bbe047fb4..d84c80206 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} за {time}', 'roadtrip.day.stopCount': 'остановок: {count}', 'roadtrip.stop.stayShort': 'Стоянка', + 'roadtrip.stop.fillTitle': 'Заправка до', + 'roadtrip.stop.fillGives': 'примерно {distance} отсюда', + 'roadtrip.stop.fillNoRange': 'Укажите запас хода, чтобы увидеть, сколько это даёт.', + 'roadtrip.stop.fillDefault': 'Взять моё значение по умолчанию', + 'roadtrip.stop.fillSet': 'Задать, насколько заправляться на этой остановке', 'roadtrip.leg.pending': 'Нет маршрута', 'roadtrip.stop.addTitle': 'Добавить как остановку', 'roadtrip.stop.kind': 'Тип остановки', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index cf2cfabc6..1a7662e7e 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} på {time}', 'roadtrip.day.stopCount': '{count} stopp', 'roadtrip.stop.stayShort': 'Stopp', + 'roadtrip.stop.fillTitle': 'Fyller till', + 'roadtrip.stop.fillGives': 'cirka {distance} härifrån', + 'roadtrip.stop.fillNoRange': 'Ange en räckvidd för att se vad det ger.', + 'roadtrip.stop.fillDefault': 'Använd mitt standardvärde', + 'roadtrip.stop.fillSet': 'Ange hur mycket det här stoppet fyller', 'roadtrip.leg.pending': 'Ingen rutt', 'roadtrip.stop.addTitle': 'Lägg till som stopp', 'roadtrip.stop.kind': 'Typ av stopp', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 1be063470..9ca75533e 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{time} içinde {distance}', 'roadtrip.day.stopCount': '{count} mola', 'roadtrip.stop.stayShort': 'Süre', + 'roadtrip.stop.fillTitle': 'Şu kadar dolar', + 'roadtrip.stop.fillGives': 'buradan yaklaşık {distance}', + 'roadtrip.stop.fillNoRange': 'Ne kazandırdığını görmek için bir menzil gir.', + 'roadtrip.stop.fillDefault': 'Kendi varsayılanımı kullan', + 'roadtrip.stop.fillSet': 'Bu molada ne kadar doldurulacağını ayarla', 'roadtrip.leg.pending': 'Rota yok', 'roadtrip.stop.addTitle': 'Mola olarak ekle', 'roadtrip.stop.kind': 'Mola türü', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index aae2b8754..39467a7db 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} за {time}', 'roadtrip.day.stopCount': 'зупинок: {count}', 'roadtrip.stop.stayShort': 'Стоянка', + 'roadtrip.stop.fillTitle': 'Заправка до', + 'roadtrip.stop.fillGives': 'приблизно {distance} звідси', + 'roadtrip.stop.fillNoRange': 'Вкажіть запас ходу, щоб побачити, скільки це дає.', + 'roadtrip.stop.fillDefault': 'Узяти моє значення за замовчуванням', + 'roadtrip.stop.fillSet': 'Задати, наскільки заправлятися на цій зупинці', 'roadtrip.leg.pending': 'Немає маршруту', 'roadtrip.stop.addTitle': 'Додати як зупинку', 'roadtrip.stop.kind': 'Тип зупинки', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 15aa0bda2..f56c11f81 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance} trong {time}', 'roadtrip.day.stopCount': '{count} điểm dừng', 'roadtrip.stop.stayShort': 'Dừng', + 'roadtrip.stop.fillTitle': 'Đổ đến', + 'roadtrip.stop.fillGives': 'khoảng {distance} từ đây', + 'roadtrip.stop.fillNoRange': 'Nhập quãng đường để thấy được bao nhiêu.', + 'roadtrip.stop.fillDefault': 'Dùng mặc định của tôi', + 'roadtrip.stop.fillSet': 'Đặt mức đổ tại điểm dừng này', 'roadtrip.leg.pending': 'Không có lộ trình', 'roadtrip.stop.addTitle': 'Thêm làm điểm dừng', 'roadtrip.stop.kind': 'Loại điểm dừng', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 04cca91c7..c6e76c0b9 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance},{time}', 'roadtrip.day.stopCount': '{count} 個停靠點', 'roadtrip.stop.stayShort': '停留', + 'roadtrip.stop.fillTitle': '加到', + 'roadtrip.stop.fillGives': '從這裡約 {distance}', + 'roadtrip.stop.fillNoRange': '填寫續航後,這裡會顯示能跑多遠。', + 'roadtrip.stop.fillDefault': '使用我的預設值', + 'roadtrip.stop.fillSet': '設定這一站加到多少', 'roadtrip.leg.pending': '無路線', 'roadtrip.stop.addTitle': '新增為停靠點', 'roadtrip.stop.kind': '停靠類型', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 79a9ca89f..9d8255b69 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = { 'roadtrip.leg.driveText': '{distance},{time}', 'roadtrip.day.stopCount': '{count} 个停靠点', 'roadtrip.stop.stayShort': '停留', + 'roadtrip.stop.fillTitle': '加到', + 'roadtrip.stop.fillGives': '从这里约 {distance}', + 'roadtrip.stop.fillNoRange': '填写续航后,这里会显示能跑多远。', + 'roadtrip.stop.fillDefault': '使用我的默认值', + 'roadtrip.stop.fillSet': '设置这一站加到多少', 'roadtrip.leg.pending': '无路线', 'roadtrip.stop.addTitle': '添加为停靠点', 'roadtrip.stop.kind': '停靠类型', diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index f067c3d11..f01e90484 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -94,6 +94,14 @@ export type PlaceRatingVote = z.infer; export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']); export type RoadtripStopType = z.infer; +/** + * A percentage of a tank, 1 to 100, or null for "no opinion, use the setting". + * + * Integer because a tenth of a percent of a tank is not a thing anybody knows about + * their own car, and bounded away from zero because a fill of nothing is not a fill. + */ +const fillPercentSchema = z.number().int().min(1).max(100).nullable().optional(); + export const placeSchema = z.object({ id: z.number(), trip_id: z.number(), @@ -128,6 +136,16 @@ export const placeSchema = z.object({ * already knows and that owns its own icon and colour. */ stop_type: roadtripStopTypeSchema.nullable().optional(), + /** + * How full THIS stop fills the tank, 1-100 (#1797). null follows the traveller's own + * setting, which is every place that predates the field. + * + * A property of the stop rather than of the person: a motorway rapid charger is worth + * 80 % because the last fifth costs as long again, while the one at the hotel is worth + * 100 % because the car stands there all night. One figure for the whole trip cannot + * say both, and the difference between them is a leg. + */ + fill_percent: fillPercentSchema, website: z.string().nullable().optional(), phone: z.string().nullable().optional(), transport_mode: z.string().nullable().optional(), @@ -175,6 +193,9 @@ export const assignmentPlaceSchema = z.object({ // Carried through so the road-trip rail can mark a fuel stop as one without a // second request per stop. stop_type: roadtripStopTypeSchema.nullable().optional(), + // Carried for the same reason as stop_type: the rail resets a range budget at this + // stop and needs to know how far it fills without a request per stop. + fill_percent: fillPercentSchema, category: placeCategorySchema.optional(), tags: z.array(tagSchema.partial()).optional(), }); @@ -191,7 +212,13 @@ export type AssignmentPlace = z.infer; * Nullable: an explicit null is how a fuel stop becomes an ordinary place again, and the * service already reads it that way rather than as "leave alone". */ -const stopTypeField = z.object({ stop_type: roadtripStopTypeSchema.nullable().optional() }); +const stopTypeField = z.object({ + stop_type: roadtripStopTypeSchema.nullable().optional(), + // Named for the same reason: a bounded vocabulary on an otherwise open body. Zero is + // outside it on purpose — a stop that fills nothing is not a stop, and letting one + // through would leave a range budget that never resets. + fill_percent: fillPercentSchema, +}); export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) })).and(stopTypeField); export type PlaceCreateRequest = z.infer; From bd6e6312e18943c077328155f0a11a8f344c2d7e Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 10 Sep 2026 19:32:19 +0200 Subject: [PATCH 20/29] fix(roadtrip): keep a typed range when the parts round away to nothing A consumption typed as 500 rather than 5.0 makes the tank arithmetic round to a range of zero. Zero is a number, so the `??` in effectiveRangeKm took it as the answer and discarded the range the traveller had typed by hand next to it, and useVehicleRange then folded that zero to null. What reached the rail was "no range at all": every fuel and charge warning went quiet, with nothing on screen naming the field that did it. A range that rounds below one kilometre is a typo rather than a car, so it is reported as no answer and the typed figure gets its turn. --- .../components/Roadtrip/vehicleRange.test.ts | 16 ++++++++++++++++ client/src/components/Roadtrip/vehicleRange.ts | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/client/src/components/Roadtrip/vehicleRange.test.ts b/client/src/components/Roadtrip/vehicleRange.test.ts index 9e5cf4f01..eaabec5e1 100644 --- a/client/src/components/Roadtrip/vehicleRange.test.ts +++ b/client/src/components/Roadtrip/vehicleRange.test.ts @@ -105,6 +105,22 @@ describe('unit round trip', () => { expect(showSpec('batteryKwh', 58, true)).toBe(58) }) + it('FE-ROADTRIP-VEHRANGE-016: a consumption typed a hundred times too high does not silence the typed range', () => { + // A consumption of 500 rather than 5.0 makes the parts round to nothing. Zero used + // to count as an answer, so the `??` in effectiveRangeKm never reached the 500 km + // the traveller had typed by hand, and every range warning went quiet with nothing + // on screen to say which field did it. + expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 500 })).toBeNull() + expect(effectiveRangeKm('combustion', { tankLitres: 1, litresPer100: 500 }, 500)).toBe(500) + + // Same shape on the other kind of car, where a decimal point is just as easy to lose. + expect(rangeFromSpec('electric', { batteryKwh: 0.5, kwhPer100: 200 })).toBeNull() + expect(effectiveRangeKm('electric', { batteryKwh: 0.5, kwhPer100: 200 }, 420)).toBe(420) + + // One kilometre is still a figure, so the cut is below it and not at some round guess. + expect(rangeFromSpec('combustion', { tankLitres: 1, litresPer100: 100 })).toBe(1) + }) + it('FE-ROADTRIP-VEHRANGE-015: an empty field stays empty rather than becoming a zero', () => { expect(showSpec('tankLitres', undefined, false)).toBeUndefined() expect(showSpec('tankLitres', 0, true)).toBeUndefined() diff --git a/client/src/components/Roadtrip/vehicleRange.ts b/client/src/components/Roadtrip/vehicleRange.ts index 44532e47c..cda588664 100644 --- a/client/src/components/Roadtrip/vehicleRange.ts +++ b/client/src/components/Roadtrip/vehicleRange.ts @@ -46,12 +46,25 @@ function positive(value: number | null | undefined): number | null { * Null rather than a guess whenever a piece is missing: half a specification is not a * range, and a made-up figure here becomes a fuel warning that fires at the wrong place. */ +/** + * A rounded range that came out at nothing is a typo, not a car. + * + * A consumption typed as 500 rather than 5.0 rounds to a range of zero, and zero is a + * number: `effectiveRangeKm` would take it as the answer and throw away the range the + * traveller had typed by hand right next to it. What reached the rail was "no range at + * all", so every warning went quiet, with nothing on screen to say which field did it. + * Reported as nothing instead, so the typed figure gets its turn. + */ +function atLeastAKilometre(km: number): number | null { + return km >= 1 ? km : null +} + export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: VehicleSpec): number | null { if (vehicle === 'combustion') { const tank = positive(spec.tankLitres) const per100 = positive(spec.litresPer100) if (!tank || !per100) return null - return Math.round((tank / per100) * 100) + return atLeastAKilometre(Math.round((tank / per100) * 100)) } if (vehicle === 'electric') { @@ -63,7 +76,7 @@ export function rangeFromSpec(vehicle: VehicleKind | null | undefined, spec: Veh // the first metre of the trip. const lost = Math.min(90, Math.max(0, positive(spec.degradationPercent) ?? 0)) const usable = battery * (1 - lost / 100) - return Math.round((usable / per100) * 100) + return atLeastAKilometre(Math.round((usable / per100) * 100)) } return null From 3f0eb210d83d555c5d7335c8352f5afcd6e66947 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 10 Sep 2026 19:33:23 +0200 Subject: [PATCH 21/29] feat(roadtrip): move a stop reached after midnight onto the day it is reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A day that runs past midnight used to keep every one of its stops on its own card, so day one ended at 03:12 with three places nobody reaches on day one and day two began with a drive that had already happened. The rail now lays the stops out by the date they are actually reached: when the clock goes backwards between two arrivals the ones after it move to the following day, into a block that says where they came from, and the night drive and its kilometres go with them. It is an arrangement, not a write — the day planner beside it is untouched, nothing reaches the database, and a shorter stay the evening before puts everything back on its own. The map follows the same arrangement, because a boundary drawn in the rail and not on the map is two answers to one question. Day one now ends where its last reachable stop is and the line, the legs and the totals are cut to match. Two switches in the driving settings, off by default. "Connect the days" routes the gap between one day's last stop and the next day's first, which is real driving the rail has never drawn, and counts it. "A colour per day" then puts the days back into a line that has become continuous, and tints each day header to match. Collapsing a day from its header takes its route and its places off the map as well as out of the rail, and the categories in the corridor panel fold the same way. The refuel band is one full-width red band with the pump as its button, the results are chips, and the driving dialog is two columns of panels with the figures on one right edge. Also fixes a via that would not adjust the route once dragged. Five separate causes, all measured rather than guessed: leg threading used the card index instead of the owning day's, the anchor lookup returned chain indices, it filtered by the card rather than by the anchor's owner, seams were routed without their vias, and a seam once routed was never re-fetched. --- client/src/components/Map/MapView.tsx | 7 +- client/src/components/Map/MapViewGL.tsx | 64 +- .../src/components/Map/RoadtripViaMarkers.tsx | 25 +- .../Roadtrip/AnchoredPopover.test.tsx | 261 ++++++ .../components/Roadtrip/RangeStrip.test.tsx | 228 ++++++ client/src/components/Roadtrip/RangeStrip.tsx | 161 ++++ .../Roadtrip/RoadtripCorridorPanel.test.tsx | 20 +- .../Roadtrip/RoadtripCorridorPanel.tsx | 87 +- .../Roadtrip/RoadtripLimitsCard.test.tsx | 74 +- .../Roadtrip/RoadtripLimitsCard.tsx | 584 +++++++------- .../Roadtrip/RoadtripSidebar.test.tsx | 102 ++- .../components/Roadtrip/RoadtripSidebar.tsx | 747 +++++++++++++----- .../src/components/Roadtrip/corridor.test.ts | 47 ++ client/src/components/Roadtrip/corridor.ts | 55 ++ .../src/components/Roadtrip/dayColors.test.ts | 34 + client/src/components/Roadtrip/dayColors.ts | 43 + .../components/Roadtrip/nightSpill.test.ts | 258 ++++++ client/src/components/Roadtrip/nightSpill.ts | 212 +++++ .../Roadtrip/useFollowTrack.test.ts | 6 +- .../Roadtrip/useRefuelSearch.test.ts | 282 +++++++ .../Roadtrip/useRoadtripCorridor.test.ts | 6 +- .../Roadtrip/useRoadtripRoutes.test.ts | 68 ++ .../components/Roadtrip/useRoadtripRoutes.ts | 398 ++++++++-- .../components/Roadtrip/useRoadtripVias.ts | 39 + .../Roadtrip/useVehicleRange.test.ts | 199 +++++ client/src/index.css | 31 +- client/src/pages/TripPlannerPage.tsx | 9 +- .../src/pages/tripPlanner/useTripPlanner.ts | 134 +++- client/src/store/settingsStore.ts | 7 + client/src/types.ts | 4 + shared/src/i18n/ar/roadtrip.ts | 18 + shared/src/i18n/br/roadtrip.ts | 18 + shared/src/i18n/ca/roadtrip.ts | 18 + shared/src/i18n/cs/roadtrip.ts | 18 + shared/src/i18n/de/roadtrip.ts | 18 + shared/src/i18n/en/roadtrip.ts | 18 + shared/src/i18n/es/roadtrip.ts | 18 + shared/src/i18n/fr/roadtrip.ts | 18 + shared/src/i18n/gr/roadtrip.ts | 18 + shared/src/i18n/hu/roadtrip.ts | 18 + shared/src/i18n/id/roadtrip.ts | 18 + shared/src/i18n/it/roadtrip.ts | 18 + shared/src/i18n/ja/roadtrip.ts | 18 + shared/src/i18n/ko/roadtrip.ts | 18 + shared/src/i18n/nl/roadtrip.ts | 18 + shared/src/i18n/pl/roadtrip.ts | 18 + shared/src/i18n/ru/roadtrip.ts | 18 + shared/src/i18n/sv/roadtrip.ts | 18 + shared/src/i18n/tr/roadtrip.ts | 18 + shared/src/i18n/uk/roadtrip.ts | 18 + shared/src/i18n/vi/roadtrip.ts | 18 + shared/src/i18n/zh-TW/roadtrip.ts | 18 + shared/src/i18n/zh/roadtrip.ts | 18 + 53 files changed, 3980 insertions(+), 626 deletions(-) create mode 100644 client/src/components/Roadtrip/AnchoredPopover.test.tsx create mode 100644 client/src/components/Roadtrip/RangeStrip.test.tsx create mode 100644 client/src/components/Roadtrip/RangeStrip.tsx create mode 100644 client/src/components/Roadtrip/dayColors.test.ts create mode 100644 client/src/components/Roadtrip/dayColors.ts create mode 100644 client/src/components/Roadtrip/nightSpill.test.ts create mode 100644 client/src/components/Roadtrip/nightSpill.ts create mode 100644 client/src/components/Roadtrip/useRefuelSearch.test.ts create mode 100644 client/src/components/Roadtrip/useVehicleRange.test.ts diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0af947b93..5d17af5f6 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -606,6 +606,9 @@ export const MapView = memo(function MapView({ places = [], dayPlaces = [], route = null, + // One colour pair per entry of `route`, or absent for the blue the route has always + // been. Only the road trip passes these, and only while colouring by day is on. + routeColors = null, routeSegments = [], selectedPlaceId = null, hoverDisabled = false, @@ -1032,7 +1035,7 @@ export const MapView = memo(function MapView({ { @@ -1046,7 +1049,7 @@ export const MapView = memo(function MapView({ , ] : [])} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 015e9d604..0e830a51f 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -123,6 +123,11 @@ interface Props { /** The dashed last bit to a place the road network does not reach. */ accessLines?: { line: [[number, number], [number, number]]; meters: number }[] route?: [number, number][][] | null + /** + * One colour pair per entry of `route`, or absent for the blue the route has always + * been. Only the road trip passes these, and only while colouring by day is on. + */ + routeColors?: ({ line: string; casing: string } | undefined)[] | null routeSegments?: RouteSegment[] selectedPlaceId?: number | null onMarkerClick?: (id: number) => void @@ -606,6 +611,7 @@ export function MapViewGL({ routeVias = NO_ROUTE_VIAS, accessLines = NO_ACCESS_LINES, route = null, + routeColors = null, routeSegments = NO_ROUTE_SEGMENTS, selectedPlaceId = null, hoverDisabled = false, @@ -762,6 +768,23 @@ export function MapViewGL({ viaCleanupRef.current = [] viaPinsRef.current.forEach(p => p.remove()) viaPinsRef.current = [] + /** + * Zoomed out, the handles go away. + * + * A via is a handle for a few hundred metres of road, and at a continental zoom a + * whole day's worth of them collapses into a cluster of dots over one town — not + * something anybody can aim at, and dragging one there moves the route by kilometres + * per pixel. Below this the drive is read, not shaped. + */ + const VIA_MIN_ZOOM = 9 + const applyViaZoom = () => { + const on = map.getZoom() >= VIA_MIN_ZOOM + for (const pin of viaPinsRef.current) { + const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.() + if (node) node.style.display = on ? '' : 'none' + } + } + for (const via of vias) { const el = document.createElement('span') el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none' @@ -850,8 +873,26 @@ export function MapViewGL({ el.removeEventListener('dblclick', swallow) }) } + + // Applied now and on every zoom that settles, so a handle that should not be there is + // gone before the first frame rather than after the first gesture. + applyViaZoom() + map.on('zoomend', applyViaZoom) + viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom)) }, [vias, viasDraggable, mapReady, glProvider]) + /** + * The other people's pointers, and this person's own going the other way. + * + * Built as plain elements on the same pin layer the vias use, so they ride the map + * during a pan instead of being re-placed a frame later — the same reason the via + * handles are not library markers. + * + * No interpolation between frames, unlike the studio's book: a map pans and zooms under + * the arrow, so a position eased towards over several frames is a position that was + * never true at any of them. Ten frames a second placed exactly reads as a hand; the + * same frames chasing a moving target read as a drift. + */ /** * The offered routes, drawn under the current one. * @@ -1178,7 +1219,9 @@ export function MapViewGL({ id: 'trip-route-casing', type: 'line', source: 'trip-route', - paint: { 'line-color': '#0a5cc2', 'line-width': 8 }, + // Per feature where the caller gave one, else the blue the route has always + // been. `coalesce` rather than a second layer: one source, one stroke. + paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) // An invisible band over the route, purely to be clicked. The drawn line is 8px @@ -1195,7 +1238,7 @@ export function MapViewGL({ id: 'trip-route-line', type: 'line', source: 'trip-route', - paint: { 'line-color': '#0a84ff', 'line-width': 5 }, + paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) } @@ -1885,13 +1928,18 @@ export function MapViewGL({ if (!map) return const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined if (!src) return - const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({ - type: 'Feature' as const, - properties: {}, - geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, - })) + const features = (route || []) + .map((seg, i) => ({ seg, colors: routeColors?.[i] })) + .filter(({ seg }) => seg && seg.length > 1) + .map(({ seg, colors }) => ({ + type: 'Feature' as const, + // Null rather than absent: `coalesce` in the paint expression falls through on + // null, and an absent property would make every line the default colour. + properties: { color: colors?.line ?? null, casing: colors?.casing ?? null }, + geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, + })) src.setData({ type: 'FeatureCollection', features }) - }, [route, mapReady]) + }, [route, routeColors, mapReady]) // Update access-spur geojson useEffect(() => { diff --git a/client/src/components/Map/RoadtripViaMarkers.tsx b/client/src/components/Map/RoadtripViaMarkers.tsx index fb6cab5ff..484258cbb 100644 --- a/client/src/components/Map/RoadtripViaMarkers.tsx +++ b/client/src/components/Map/RoadtripViaMarkers.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useRef } from 'react' -import { Marker, Tooltip } from 'react-leaflet' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Marker, Tooltip, useMap } from 'react-leaflet' import L from 'leaflet' import { useTranslation } from '../../i18n/TranslationContext' import { useStableVias } from './viaMarkerState' @@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps { } /** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */ +/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */ +const VIA_MIN_ZOOM = 9 + export default function RoadtripViaMarkers({ viasByDay, onMoveVia, onRemoveVia, }: RoadtripViaMarkersProps): React.ReactElement | null { @@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({ const handlersRef = useRef({ onMoveVia, onRemoveVia }) handlersRef.current = { onMoveVia, onRemoveVia } const draggable = !!onMoveVia + /** + * Zoomed out, the handles go away. + * + * A via is a handle for a few hundred metres of road, and at a continental zoom a whole + * day's worth collapses into a cluster of dots over one town — not something anybody can + * aim at, and a drag there moves the route by kilometres per pixel. Below this the drive + * is read, not shaped. + */ + const map = useMap() + const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM) + useEffect(() => { + const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM) + update() + map.on('zoomend', update) + return () => { map.off('zoomend', update) } + }, [map]) const markers = useMemo(() => vias.map(via => ({ via, @@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({ }, })), [vias]) - if (!markers.length) return null + if (!markers.length || !zoomedIn) return null return ( <> diff --git a/client/src/components/Roadtrip/AnchoredPopover.test.tsx b/client/src/components/Roadtrip/AnchoredPopover.test.tsx new file mode 100644 index 000000000..0507a1272 --- /dev/null +++ b/client/src/components/Roadtrip/AnchoredPopover.test.tsx @@ -0,0 +1,261 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import AnchoredPopover from './AnchoredPopover' + +/** + * FE-ANCHOREDPOP-001..014 — where the panel lands and what makes it go away. + * + * Everything here is geometry read off the page and arithmetic nobody sees until it is + * wrong: a panel a gap below the badge, above it on a short window, pulled back inside + * the viewport, centred on the rail rather than on the badge. The numbers are worked out + * in the comments so a changed rule fails with a readable diff instead of a shrug. + * + * jsdom lays nothing out — every getBoundingClientRect comes back as zeroes, so badge, + * rail and panel all measure the same and every branch of the placement collapses onto + * 0,0. Sizes are therefore fed in per element, and window.innerHeight / innerWidth are + * stated rather than left to the jsdom default, since the flip and the clamp are the + * whole point of half these cases. + */ + +const rect = (top: number, left: number, width: number, height: number): DOMRect => ({ + top, left, width, height, + bottom: top + height, + right: left + width, + x: left, + y: top, + toJSON: () => ({}), +}) as DOMRect + +let section: HTMLElement +let anchor: HTMLElement +let panel: DOMRect + +function viewport(width: number, height: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width }) + Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height }) +} + +beforeEach(() => { + section = document.createElement('section') + anchor = document.createElement('button') + section.appendChild(anchor) + document.body.appendChild(section) + // The rail as it stands on a desktop: a tall column at the left edge with the badge + // hard against its far side. That offset is what the centring rule exists for. + section.getBoundingClientRect = () => rect(100, 0, 400, 600) + anchor.getBoundingClientRect = () => rect(120, 344, 48, 20) + panel = rect(0, 0, 200, 150) + viewport(1200, 800) + // The panel is the one element that cannot be primed by hand: React makes it inside the + // portal and the layout effect measures it in the tick it is created. So it answers off + // the prototype, which the two own assignments above shadow. Only its size is read — + // handing back a top and a left it does not have yet would make the test circular. + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => panel) +}) + +afterEach(() => { + // Both, because case 006 moves the anchor out of the section to test the fallback, so + // it would outlive section.remove() and sit in the document for the rest of the file. + anchor.remove() + section.remove() + vi.restoreAllMocks() +}) + +function open() { + const onClose = vi.fn() + const view = render( + + + , + ) + return { onClose, unmount: view.unmount, rerender: view.rerender } +} + +const dialog = () => screen.getByRole('dialog') + +describe('AnchoredPopover', () => { + it('FE-ANCHOREDPOP-001: without an anchor there is nothing to hang under, and nothing listening', () => { + const onClose = vi.fn() + render( + + + , + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + // A closed panel that keeps its key handler takes Escape away from whatever is + // genuinely open behind it. + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-002: it hangs a gap under the badge, and is shown only once it has been placed', () => { + open() + + // Badge bottom 140 plus the gap. Fixed is asserted as the class rather than as a + // computed style, because jsdom resolves no stylesheet: it is what makes every + // number in this file a viewport coordinate instead of an offset inside a scroller. + expect(dialog()).toHaveStyle({ top: '148px' }) + expect(dialog().className).toContain('fixed') + expect(screen.getByRole('dialog', { name: 'Stop kind' })).toBeInTheDocument() + }) + + it('FE-ANCHOREDPOP-015: it is portalled to the body, out of the rail that would clip it', () => { + open() + + // The reason this component exists at all. The rail scrolls and carries transforms, + // and both of those trap a fixed child, so a panel rendered where it is declared + // would be clipped by the sidebar it hangs in. Every other case here passes without + // the portal, which is why this one asserts the parent rather than the geometry. + expect(dialog().parentElement).toBe(document.body) + expect(section.contains(dialog())).toBe(false) + }) + + it('FE-ANCHOREDPOP-016: moving to another badge re-measures against that one', () => { + const { onClose, rerender } = open() + + const second = document.createElement('button') + section.appendChild(second) + second.getBoundingClientRect = () => rect(500, 344, 48, 20) + rerender( + + + , + ) + + // 500 plus 20 plus the gap. The rail has more than one badge worth opening and the + // same panel serves all of them, so the anchor really does change under a mounted + // popover. Measuring in a layout effect is what keeps that from ever being painted + // at the previous badge: the position is committed before the browser draws. + expect(dialog()).toHaveStyle({ top: '528px' }) + second.remove() + }) + + it('FE-ANCHOREDPOP-003: on a short window it flips above the badge instead of off the bottom', () => { + anchor.getBoundingClientRect = () => rect(200, 344, 48, 20) + viewport(1200, 360) + + open() + + // Below would start at 228 and run to 378, past the 352 the window leaves. Above is + // badge top 200 less the 150 it needs less the gap. + expect(dialog()).toHaveStyle({ top: '42px' }) + }) + + it('FE-ANCHOREDPOP-004: a panel too tall for either side sits at the top edge rather than above it', () => { + anchor.getBoundingClientRect = () => rect(40, 344, 48, 20) + panel = rect(0, 0, 200, 250) + viewport(1200, 320) + + open() + + // Flipping honestly would put it at -218, scrolling the half that holds the controls + // out of reach with nothing to scroll back. Covering the badge is the lesser evil. + expect(dialog()).toHaveStyle({ top: '8px' }) + }) + + it('FE-ANCHOREDPOP-005: it centres on the rail, not on the badge sitting at the edge of it', () => { + open() + + // Rail centre 200 less half the panel. Centred on the badge it would be 268, hanging + // over the map and pointing at nothing. + expect(dialog()).toHaveStyle({ left: '100px' }) + }) + + it('FE-ANCHOREDPOP-006: with no column around it, it falls back to the badge', () => { + // Same badge, no rail: the panel is not owned by the sidebar and has to place itself + // sanely wherever it is hung. + document.body.appendChild(anchor) + + open() + + // Badge centre 368 less half the panel. + expect(dialog()).toHaveStyle({ left: '268px' }) + }) + + it('FE-ANCHOREDPOP-007: a rail near the right edge does not push the panel off the screen', () => { + section.getBoundingClientRect = () => rect(100, 380, 200, 600) + viewport(500, 800) + + open() + + // Centred it would want 380 and end at 580, well past a window 500 wide. Clamped it + // ends at 492, the same margin the rest of the placement keeps. + expect(dialog()).toHaveStyle({ left: '292px' }) + }) + + it('FE-ANCHOREDPOP-008: a rail scrolled off the left does not push the panel off it either', () => { + section.getBoundingClientRect = () => rect(100, -60, 100, 600) + + open() + + // Centred on that sliver the panel would start at -110 and lose its first half. + expect(dialog()).toHaveStyle({ left: '8px' }) + }) + + it('FE-ANCHOREDPOP-009: Escape closes it, and only Escape', () => { + // The handler sits on the document, so it sees every key pressed anywhere on the + // page, typing in a field included. + const { onClose } = open() + + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'e' }) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-010: a press anywhere else closes it', () => { + const { onClose } = open() + + fireEvent.mouseDown(document.body) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-011: a press on what the panel contains leaves it open', () => { + // It closes on mousedown, not on click. Without the guard a button in the panel would + // be torn out from under the pointer before its own click ever landed. + const { onClose } = open() + + fireEvent.mouseDown(screen.getByText('Inside')) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-012: a press on the badge that opened it leaves it open too', () => { + // The badge toggles. Closing here as well means one press closes the panel twice, and + // the next press reads as the badge refusing to open. + const { onClose } = open() + + fireEvent.mouseDown(anchor) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-ANCHOREDPOP-013: a scroll closes it, including one that never bubbles up', () => { + // Scroll events do not bubble and the rail is its own scroller. A listener without + // capture would hear nothing while the badge slides out from under a panel that stays + // where it was measured. + const { onClose } = open() + + fireEvent.scroll(section) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-ANCHOREDPOP-014: once it is gone it has stopped listening', () => { + // Three handlers on the document and the window, on a component that is mounted and + // thrown away again for every badge in the rail. + const { onClose, unmount } = open() + + unmount() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.mouseDown(document.body) + fireEvent.scroll(section) + + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.test.tsx b/client/src/components/Roadtrip/RangeStrip.test.tsx new file mode 100644 index 000000000..25138fc77 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.test.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import type { DistanceUnit } from '../../types' +import RangeStrip, { blockMask } from './RangeStrip' + +/** + * FE-RANGESTRIP-001..015 — the picture under the range field. + * + * Nothing here is interactive, so what is worth pinning is the arithmetic the drawing + * rests on: the empty state has to stay empty rather than settle on a plausible zero, + * the block mask has a band it lives in and looks broken outside it, and the three + * segments have to cover the bar exactly or the tail runs off the end. The chips are the + * only readable proof that any of that happened, and the aria-label is the only route a + * screen reader has to the figure at all. + */ + +const strip = (over: Partial> = {}) => ( + +) + +/** + * The three widths of the bar, in the order they are laid: what a stop puts in, what is + * left of the tank, what age has taken. Read off the track rather than off the wrapper, + * because the wrapper is the thing carrying the label. + */ +function segments(): number[] { + const track = screen.getByRole('img').firstElementChild as HTMLElement + return Array.from(track.children).map(c => parseFloat((c as HTMLElement).style.width)) +} + +describe('RangeStrip', () => { + it('FE-RANGESTRIP-001: with no range the figure is a dash and no unit hangs off it', () => { + // A lone "km" beside a dash reads as a unit waiting for a number, which invites the + // guess that the number is zero. There is no number, so there is nothing to label. + render(strip({ rangeKm: null })) + + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.queryByText('km')).not.toBeInTheDocument() + expect(screen.getByText('No range set')).toBeInTheDocument() + expect(screen.getByText(/Type a range/)).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-002: with no range the bar reads out the hint alone', () => { + // The bar is drawn empty here, so its label is the only thing that can say why. + render(strip({ rangeKm: null })) + + expect(screen.getByRole('img')).toHaveAccessibleName( + 'Type a range, or fill in the figures below and TREK works it out.', + ) + }) + + it('FE-RANGESTRIP-003: the figure and its unit are split but say the same thing', () => { + // Split so the unit can sit smaller beside the number; the split is only safe while + // the two halves still read as one reading of one distance. + render(strip({ rangeKm: 500 })) + + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByText('km')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-004: an electric car charges where a petrol one fills', () => { + const { rerender } = render(strip({ electric: true })) + expect(screen.getByText('per charge')).toBeInTheDocument() + + rerender(strip({ electric: false })) + expect(screen.getByText('per tank')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-005: a countable range is cut into blocks and says what one is worth', () => { + // Ten blocks. The chip is the only thing that turns the gaps from texture into a + // measure, so it has to appear with the mask rather than merely near it. + render(strip({ rangeKm: 500 })) + + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-006: a range too short or too long to count is left solid', () => { + // Two blocks read as damage; forty eat the bar. Either way there is nothing to + // count, so claiming a block is worth fifty kilometres would be claiming a mask + // that was never cut. + const { rerender } = render(strip({ rangeKm: 100 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + + rerender(strip({ rangeKm: 2000 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-007: the countable band has hard edges at three and thirty blocks', () => { + // Rounded, not floored: 130 km is three blocks and 1520 km is thirty. The pairs + // either side of each edge are what a change to that rounding would move. + const { rerender } = render(strip({ rangeKm: 120 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + + rerender(strip({ rangeKm: 130 })) + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + + rerender(strip({ rangeKm: 1520 })) + expect(screen.getByText('One block = 50 km')).toBeInTheDocument() + + rerender(strip({ rangeKm: 1560 })) + expect(screen.queryByText(/One block/)).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-008: a partial fill says what that stop actually buys', () => { + // Four fifths of a 500 km tank. The whole point of the panel is that nobody charges + // to the top, so the note has to move off the full-tank sentence. + render(strip({ rangeKm: 500, fillPercent: 80 })) + + expect(screen.getByText('A 80 % stop gives 400 km')).toBeInTheDocument() + expect(screen.queryByText('A stop fills right up')).not.toBeInTheDocument() + }) + + it('FE-RANGESTRIP-009: a full, an absent and an impossible fill all read as filling right up', () => { + // Anything outside nought to a hundred is not a fraction of a tank, and a note + // saying a 130 % stop gives 650 km would be the panel inventing range. + const { rerender } = render(strip({ fillPercent: 100 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: null })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: 130 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + // The other end of the same guard, and the one a cleared or mistyped field really + // produces: a stop that puts nothing in would leave the bar empty and the note + // claiming a range of nought. + rerender(strip({ fillPercent: 0 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + + rerender(strip({ fillPercent: -20 })) + expect(screen.getByText('A stop fills right up')).toBeInTheDocument() + }) + + it('FE-RANGESTRIP-010: wear is clamped to 90 rather than drawn past the bar', () => { + // A battery reported as 150 % gone would leave the middle segment at a negative + // width, which the browser drops: the tail would then be the whole bar and the chip + // would be telling the traveller the car cannot move. + render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 150 })) + + expect(screen.getByText('90 % lost to age')).toBeInTheDocument() + expect(segments()).toEqual([8, 2, 90]) + }) + + it('FE-RANGESTRIP-011: with nothing lost to age there is no tail to explain', () => { + // Zero is the ordinary case for a new car and for every petrol one. A chip reading + // "0 % lost to age" is an answer to a question nobody asked. + const { rerender } = render(strip({ wearPercent: 0 })) + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + + rerender(strip({ wearPercent: -20 })) + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + expect(segments()).toEqual([100, 0, 0]) + }) + + it('FE-RANGESTRIP-012: without a range there is no wear to take off it', () => { + // Wear is a share of a range. With no range the share is of nothing, and a tail on + // an empty bar would be the one filled thing on it. + render(strip({ rangeKm: null, wearPercent: 30 })) + + expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument() + expect(segments()).toEqual([0, 100, 0]) + }) + + it('FE-RANGESTRIP-013: the three segments cover the bar exactly', () => { + // A fifth gone and a four-fifths stop: the fill is four fifths of what is LEFT, not + // of the factory bar, or the filled part would reach into the hatched tail. + render(strip({ rangeKm: 400, fillPercent: 80, wearPercent: 20 })) + + expect(segments()).toEqual([64, 16, 20]) + }) + + it('FE-RANGESTRIP-016: the stencil itself cuts one block per fifty kilometres', () => { + // Asserted on the function, because jsdom implements no mask property: React writes + // neither maskImage nor WebkitMaskImage and the element arrives with no style at + // all, so through the DOM every one of these cases would pass on an empty bar. + expect(blockMask(500)).toContain('repeating-linear-gradient') + // Ten blocks of fifty, so one keep-then-cut step is a tenth of the bar. + expect(blockMask(500)).toContain('10%') + // Five blocks, a fifth each: the step really follows the distance rather than being + // a fixed number of divisions dressed up as one. + expect(blockMask(250)).toContain('20%') + + // Below three the cuts read as damage and above thirty the gaps eat the bar, so + // outside that the bar is honestly solid. + expect(blockMask(100)).toBeUndefined() + expect(blockMask(2000)).toBeUndefined() + expect(blockMask(null)).toBeUndefined() + }) + + it('FE-RANGESTRIP-017: the wear chip carries the same hatching as the tail of the bar', () => { + // The swatch is what ties the sentence to the stripe. Without it the hatching is an + // unexplained texture at the end of the bar and the chip an unexplained subtraction. + const { container } = render(strip({ rangeKm: 400, wearPercent: 12, electric: true })) + + const hatched = Array.from(container.querySelectorAll('span[style*="repeating-linear-gradient"]')) + expect(hatched).toHaveLength(1) + expect(hatched[0].style.backgroundImage).toBe( + (screen.getByRole('img').firstElementChild?.lastElementChild as HTMLElement).style.backgroundImage, + ) + }) + + it('FE-RANGESTRIP-014: the bar reads out the figure and the note together', () => { + // Everything else in here is colour and width. A screen reader gets the range from + // this label or not at all, so it has to carry both halves of the answer. + render(strip({ rangeKm: 500, fillPercent: 80 })) + + expect(screen.getByRole('img')).toHaveAccessibleName('500 km — A 80 % stop gives 400 km') + }) + + it('FE-RANGESTRIP-015: imperial converts the figure, the unit and the block chip together', () => { + // The blocks are cut in kilometres whatever the setting, so the chip is the one + // place the two systems can drift apart: 50 km of bar has to be stated as 31.1 mi. + render(strip({ rangeKm: 500, unit: 'imperial' as DistanceUnit })) + + expect(screen.getByText('310.7')).toBeInTheDocument() + expect(screen.getByText('mi')).toBeInTheDocument() + expect(screen.getByText('One block = 31.1 mi')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RangeStrip.tsx b/client/src/components/Roadtrip/RangeStrip.tsx new file mode 100644 index 000000000..b7c143e28 --- /dev/null +++ b/client/src/components/Roadtrip/RangeStrip.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { useTranslation } from '../../i18n/TranslationContext' +import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../utils/units' +import type { DistanceUnit } from '../../types' +import { FS } from './typeScale' + +/** + * How far the car goes on one fill, drawn as the tank it leaves the factory with. + * + * The figure above it is the answer; this is the argument that the figure is right. It + * carries the two things a bare number cannot say on its own: + * + * - The filled part is what a stop actually puts back in. Nobody charges to 100 % on the + * road, and a traveller who set 80 % is looking at four fifths of a bar, which is the + * honest picture of what the next leg has. + * - The hatched tail is what age has taken off an electric car's battery. It is already + * out of the number above, so without the tail that figure looks like an unexplained + * disagreement with the car's own data sheet. + * + * Widths animate because this sits directly under the fields that feed it: typing a + * consumption and watching the bar move is what makes the two ways of stating a range + * visibly the same thing. + * + * No box, no border, no tinted panel. It used to sit in a card of its own, which made it + * a second surface inside a dialog whose whole point is one plain column — and the box + * was doing nothing the bar was not already doing better. + */ + +/** + * One reading of the figure above it. + * + * `bg-surface-secondary` rather than the card's own colour: these sit ON the card, so the + * chip has to be a step away from it in whichever scheme is on, and a white literal would + * be a step the wrong way in the dark one. + */ +const NOTE_CHIP = 'inline-flex items-center rounded-lg bg-surface-secondary px-2 py-1 text-caption leading-snug text-content-secondary' + +/** How many kilometres one block of the bar stands for. */ +const BLOCK_KM = 50 + +/** + * The bar, cut into blocks of a fixed distance rather than into quarters. + * + * Quarters divide a bar; blocks measure a road. With one block per fifty kilometres the + * bar for a 240 km car is visibly shorter than the bar for a 600 km one, which is the + * comparison somebody typing a consumption is actually making — and it turns the bar into + * something countable rather than something to eyeball. + * + * Cut as a MASK rather than painted over: the old quarter marks were drawn in the panel's + * own colour to look like gaps, which tied them to that panel and left them visible as + * stripes anywhere else. A mask makes real holes, so whatever is behind the bar shows + * through and nothing has to know the background. + * + * Exported for its own test rather than checked through the bar: jsdom supports no mask + * property at all, so React writes neither of the two and the element reaches a test + * carrying no style whatsoever. Through the DOM the rule is unobservable. + */ +export function blockMask(rangeKm: number | null): string | undefined { + if (!rangeKm) return undefined + const blocks = Math.round(rangeKm / BLOCK_KM) + // Below three the cuts read as damage rather than as measure, and above about thirty + // the gaps eat the bar. Outside that the bar is simply solid, which is honest: at that + // range the blocks were never going to be counted. + if (blocks < 3 || blocks > 30) return undefined + const step = 100 / blocks + // Straight, not slanted. The slant was tried and read as a decorative texture rather + // than as a measure: on a bar this thin the lean is legible only as fuzz on the seams. + // + // The colour is opacity here, not paint: black is "keep", transparent is "cut". + return `repeating-linear-gradient(90deg, #000 0 calc(${step}% - 2px), transparent calc(${step}% - 2px) ${step}%)` // theme-lint-disable — mask stencil, not a colour +} + +export default function RangeStrip({ rangeKm, fillPercent, wearPercent, unit, electric }: { + rangeKm: number | null + fillPercent: number | null + wearPercent: number | null + unit: DistanceUnit + electric: boolean +}): React.ReactElement { + const { t } = useTranslation() + // Capped where the arithmetic caps it, so the picture cannot claim a battery is gone. + const wear = rangeKm ? Math.min(90, Math.max(0, wearPercent ?? 0)) : 0 + const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100 + const usable = 100 - wear + const filled = rangeKm ? (usable * fill) / 100 : 0 + const rest = 100 - wear - filled + // Split rather than formatted whole, so the unit can sit smaller beside the figure. The + // rounding is formatDistance's own, because the badge on the trigger uses that and the + // two must not disagree by a tenth. + const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null + const stripes = 'repeating-linear-gradient(135deg, var(--border-primary) 0 3px, transparent 3px 6px)' + const mask = blockMask(rangeKm) + + const note = shown === null + ? t('roadtrip.limit.rangeEmptyHint') + : fill < 100 + ? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm! * fill) / 100, unit) }) + : t('roadtrip.limit.fullNote') + + return ( +
+ {/* The figure and what it is a figure OF, on one baseline. Wrapping rather than + truncating: "per charge" is three words in English and six in Hungarian. */} +
+ + + {shown ?? '—'} + + {shown !== null ? ( + + {getDistanceUnitLabel(unit)} + + ) : null} + + + {shown === null + ? t('roadtrip.limit.rangeEmpty') + : t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')} + +
+ +
+
+
+
+
+
+
+ + {/* Three readings of this one figure — what a stop actually puts in, what one block + on the bar stands for, what age has taken off — each on its own chip inside the + card the number and the bar already share. As prose on one line they ran into + each other; given the card to themselves they looked like four separate answers. + Chips inside the card are both: three facts, one answer. */} +
+ {note} + {mask ? ( + {t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) })} + ) : null} + {/* The swatch is what ties the chip to the tail of the bar. Without it the + hatching is an unexplained stripe and the sentence an unexplained loss. */} + {wear > 0 ? ( + + + {t('roadtrip.limit.wearNote', { percent: wear })} + + ) : null} +
+
+ ) +} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index be56236b4..59dcc33b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -11,6 +11,8 @@ const wrap = (ui: React.ReactElement) => render({ui} ({ assignmentId: id, + ownerDayId: 1, + ownerIndex: id - 1, placeId: id * 10, name, lat: 53.5, @@ -41,7 +43,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({ const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ days, - lines: [], + lines: [], lineDays: [], accessLines: [], vias: [], segments: [], @@ -202,9 +204,9 @@ describe('RoadtripCorridorPanel', () => { }) wrap() - // "Fuel" also names an option in the kind picker; the group header is the one - // inside a
. - const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)! + // "Fuel" also names an option in the kind picker; the group header is the one that + // folds its list, so it is the button. + const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('button')).find(Boolean)! expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument() expect(screen.getByText('Camping Elbe')).toBeInTheDocument() }) @@ -291,11 +293,17 @@ describe('RoadtripCorridorPanel', () => { expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument() }) - it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => { + it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is not reported as a fault', () => { + // It used to say so, and it said so on nearly every search of a long day: the ceiling + // is per box and a busy corridor reaches it as a matter of course. That made a + // permanent complaint about a search that had worked, and its advice — narrow the + // corridor — makes the answer smaller rather than better. The warnings that remain + // are the ones about a search that did not happen. const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 }) wrap() - expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument() + expect(screen.queryByText(/had more than fits in one answer/)).not.toBeInTheDocument() + expect(screen.getByText('Aral')).toBeInTheDocument() }) it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => { diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index b4dd2ac26..c5b69f5b6 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import { - Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, + Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown, } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { Tooltip } from '../shared/Tooltip' @@ -85,6 +85,14 @@ function ResultBadge({ category }: { category: string }): React.ReactElement { ) } +/** + * One fact about a hit, on its own surface. + * + * `bg-surface-secondary` rather than a literal: the row lifts to `surface-hover` under the + * pointer, and a chip painted white would stop lifting with it in the dark scheme. + */ +const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted' + function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) @@ -95,16 +103,16 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
{poi.name}
- {/* Two facts, two elements. Both are translated phrases rather than short fixed - values, so they stay text and the gap does the separating a middot used to — - which also lets them wrap onto their own lines instead of breaking around a - dot left stranded at the end of a line. */} -
- {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - + {/* Two facts, two chips. Where it is off the road and how far into the drive it + comes are separate answers, and as two phrases sharing a line they read as one + run-on sentence about the same thing. A chip each gives them an edge, and the + row still wraps cleanly at a narrow width because nothing has to break around + a separator. */} +
+ + {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} + + {poi.alongKm < 0.5 ? t('roadtrip.poi.atStart') : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} @@ -167,9 +175,30 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { const meta = CATEGORY_META[category] const Icon = meta?.Icon ?? MapPin const color = serviceColor(category) + /** + * Folded away, per category. + * + * A search for fuel AND food comes back as two lists that push each other off the + * screen, and the one being read is never the one at the top. Local state on purpose: + * which group somebody has open right now is a way of looking, not a setting — it + * should not outlive the search that produced these groups. + */ + const [open, setOpen] = useState(true) + const label = meta ? t(meta.labelKey) : category return ( -
-
+ // The gap between two groups belongs to the section, not to the strip: as padding on + // the strip it became part of the hover surface, which then hung well above the row + // it highlights. +
+ {/* The whole strip is the control, not a chevron at its end: it is what the eye is + on when it decides this is not the list it wants, and at this width a 12px + target in the corner is a miss waiting to happen. */} +
-
    + {/* Quiet until the strip is under the pointer, then it says which way this goes. + A chevron that is always at full contrast reads as a fourth piece of data in a + header that already carries three. */} + + +
+ {/* Read-only once the parts add up, because then it IS the parts. Leaving + it editable would offer a second answer to a question that already has + one, and the traveller would have no way of telling which the warnings + were using. Clearing a field above hands the row straight back. */} + setPreview({ key: 'range', value: v })} + onChange={setRange} + /> + {/* Under the range, because it is a fraction OF it. Nobody charges to + 100 % on the road: the last fifth takes as long as the first four, so a + stop counted as a full tank overstates everything after it by that + fifth. */} + setPreview({ key: 'fill', value: v > 100 ? 100 : v })} + onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)} + /> + + {/* The car's own figures, folded away. Nothing here is required — somebody + who knows their range types it above and never opens this — but it + starts open the moment any of them is stored, because otherwise the + range row is read-only with its cause out of sight. */} + {vehicleKind ? ( + <> + + {/* `hidden` rather than unmounting: it really leaves the tab order, + and a field that kept a draft while folded away would be a draft + nobody can see. */} + {/* The display class only while it is open. `[hidden]` sets + `display: none` in the preflight, but a `flex` class beats it on + specificity — so the attribute flipped, the semantics were right, + and the panel stayed on screen. */} + + + ) : null} + +
) : null} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index aef384da9..dfd206cfe 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -12,6 +12,11 @@ const wrap = (ui: React.ReactElement) => render({ui} & { assignmentId: number; name: string }): RoadtripStop { return { placeId: over.assignmentId * 10, + // Filled in by `day()` from the card it is put on, unless the case under test says + // otherwise: a stop names the day it is STORED on, which after a night drive is not + // the card it is drawn on. -1 is "not stated", never a real id. + ownerDayId: -1, + ownerIndex: -1, lat: 53.5, lng: 9.9, time: null, @@ -31,12 +36,12 @@ function day(over: Partial = {}): RoadtripDay { stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' }), ] + const dayId = over.dayId ?? 1 return { - dayId: 1, + dayId, dayNumber: 1, date: null, title: null, - stops, legs: [leg()], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] }, @@ -44,6 +49,13 @@ function day(over: Partial = {}): RoadtripDay { distance: 100000, duration: 3600, ...over, + // After the spread, because `over` carries the raw stops this was built from: the + // same pair the hook fills in, for every stop that did not state its own. + stops: stops.map((s, i) => ({ + ...s, + ownerDayId: s.ownerDayId === -1 ? dayId : s.ownerDayId, + ownerIndex: s.ownerIndex === -1 ? i : s.ownerIndex, + })), } } @@ -51,6 +63,7 @@ function routes(over: Partial = {}): RoadtripRoutes { return { days: [day()], lines: [], + lineDays: [], segments: [], accessLines: [], vias: [], @@ -201,11 +214,15 @@ describe('RoadtripSidebar', () => { } wrap() - // Said twice on purpose: once as the break in the chain, once on the arrival after - // it, where "01:30" would otherwise read as tonight. - expect(screen.getAllByText('Next day')).toHaveLength(2) - // The day marker rides on the arrival that crossed midnight, where "01:30" would - // otherwise read as tonight. + // The crossing is no longer a band in the chain: a drive past midnight hands its + // stops to the next day's card, and the block around them there is what says so + // (`nightSpill.ts`). What is left on a day that could not hand them on — the last day + // of a trip, or one whose arrivals are supplied directly as they are here — is the + // marker on the arrival, where "01:30" would otherwise read as tonight. + // One mention, not two: the marker on the arrival, spelled out for a screen reader. + // The band that used to repeat it in the chain is gone — a drive past midnight hands + // its stops to the next day's card now, and the block around them there says it. + expect(screen.getAllByText('Next day')).toHaveLength(1) expect(screen.getByText('01:30').textContent).toContain('+1') }) @@ -224,7 +241,9 @@ describe('RoadtripSidebar', () => { } wrap() - expect(screen.getAllByText('Next day')).toHaveLength(2) + // Both findings still reach the stop; only the band that used to repeat the crossing + // is gone. Reading just the first warning used to drop the late flag entirely. + expect(screen.getByText('01:30').textContent).toContain('+1') expect(screen.getByLabelText(/45/)).toBeInTheDocument() }) @@ -557,6 +576,73 @@ describe('RoadtripSidebar', () => { wrap() expect(screen.queryByRole('button', { name: 'Track' })).toBeNull() }) + + /** + * The block a night drive leaves on the next morning's card. + * + * The stops are stored on the day they set off from and drawn on the day they are + * reached, so what is asserted here is that the block says where they came from and + * that the drive through the night is drawn with them. See `nightSpill.ts`. + */ + describe('a stretch driven onto this day through the night', () => { + const spilled = () => day({ + dayNumber: 2, + stops: [ + stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }), + stop({ assignmentId: 2, name: 'Wittenberg' }), + ], + spills: [{ + at: 0, + count: 1, + fromDayNumber: 1, + departure: '22:30', + leg: leg({ distance: 253000, duration: 10380 }), + line: [[53, 10], [52, 11]], + fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }), + }], + }) + + it('FE-ROADTRIP-SIDEBAR-031: names the day the stretch came from', () => { + wrap() + expect(screen.getByText('From day 1')).toBeTruthy() + }) + + it('FE-ROADTRIP-SIDEBAR-032: draws the night drive, so the kilometres reach the stops', () => { + wrap() + // Without it the card would gain a stop and none of the driving that reaches it. + expect(screen.getByText('253 km in 2 h 53 min')).toBeTruthy() + expect(screen.getByText('leaves 22:30')).toBeTruthy() + }) + + it('FE-ROADTRIP-SIDEBAR-030: a drag names the day the stop is STORED on, not the card', () => { + const onReorderStop = vi.fn() + const onMoveStopToDay = vi.fn() + const { container } = wrap() + + // Dropping the borrowed stop onto its neighbour is a reorder inside day 9 — the day + // the server knows it by — even though both are drawn under day 2. + // The draggable rows only: the block around the borrowed stretch is a list item + // too, and it contains the text of every stop inside it. + const rows = container.querySelectorAll('li[draggable="true"]') + const dataTransfer = { effectAllowed: '', setData: vi.fn() } + fireEvent.dragStart(rows[0], { dataTransfer }) + fireEvent.dragOver(rows[1], { dataTransfer }) + fireEvent.drop(rows[1], { dataTransfer }) + // Dragged from day 9 index 3 — where the server has it — onto day 1 index 1, the + // stored position of the row it was dropped on. Neither number is the card's. + expect(onMoveStopToDay).toHaveBeenCalledWith(9, 1, 1, 1) + expect(onReorderStop).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-SIDEBAR-033: an ordinary day draws no block at all', () => { + wrap() + expect(screen.queryByText(/From day/)).toBeNull() + }) + }) }) describe('RoadtripModeSwitch', () => { diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 984398a33..22e9d271d 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X, BatteryCharging, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' -import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange, type VehicleKind} from './roadtripModel' +import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { STOP_KIND_BY_KEY } from './stopKinds' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' @@ -21,6 +21,8 @@ import StopFillPicker from './StopFillPicker' import { useVehicleRange } from './useVehicleRange' import type { RoadtripStopType } from '@trek/shared' import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' +import type { SpillMark } from './nightSpill' +import { dayColor } from './dayColors' import type { RouteVia } from '../../types' import { FS } from './typeScale' import type { RouteSegment } from '../../types' @@ -83,6 +85,16 @@ interface RoadtripSidebarProps { viaCounts?: Record /** The name of the track each day follows, so the badge can say which road it is. */ trackNames?: Record + /** + * Days folded down to their header, by id. + * + * A long trip is a long rail, and reading the shape of one day means scrolling past the + * four before it. Folding is also what takes that day OFF the map: a card with nothing + * under its header would otherwise still be drawing its road. + */ + collapsedDayIds?: Set + /** Absent leaves every card open and its header unclickable. */ + onToggleDay?: (dayId: number) => void } const MODE_ICON: Record = { @@ -109,6 +121,15 @@ const RAIL_DASH: React.CSSProperties = { backgroundImage: 'repeating-linear-gradient(var(--border-primary) 0 4px, transparent 4px 8px)', } +/** + * A figure in the two-part shell the rail uses everywhere: what it is on the left, the + * number on the right, one hairline between them. + * + * Named because more than one place builds it now — the walk from the road, and the two + * figures on a refuel offer — and a second copy is how the two drift apart. + */ +const FIGURE_BADGE = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge' + /** A 24px disc — a stop's number, or a service stop's icon. */ const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full' @@ -414,60 +435,115 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // What the traveller drives, so the band says the right word and offers the right + // thing. An electric car does not run out of TANK, and a lamp shaped like a pump on a + // band about a battery is the kind of detail that makes the rest look careless. + const { vehicleKind } = useVehicleRange() + const electric = vehicleKind === 'electric' + const DryIcon = electric ? Zap : Fuel const key = `${dayId}:${dry.legIndex}` const open = refuel.openFor === key const settled = open && !refuel.loading && refuel.outcome return ( -
- - - -
- {/* Built as a drive band, because that is what it belongs to: the leg the fuel - runs out on. Same height, same type, same rail. The warning ring is the only - difference, and it is the whole message — this is the band where the tank - ends. */} -
-
- - - {t('roadtrip.refuel.dry')} - - {/* How far INTO this leg, where a drive band keeps its figures. Not the range - that was crossed: that is the traveller's own setting, says nothing about - where, and made every band on a day read the same number. */} - - {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} - - {/* An icon, the size of the shuffle mark a drive band carries, because the - band has to stay one line high beside its neighbours. What it does is in - the tooltip and in the label a screen reader gets. */} +
+ {/* Across the whole rail, marker column included, the way the day change used to + be drawn. It belongs to no stop: the tank runs out between two of them, and + hanging it off the one after would put it where somebody finds out rather than + where it happens. Full width is also what makes it the ONLY thing saying this — + the per-stop "so far on this tank" badge stands down wherever a search can be + run, because after the band has said where the fuel ends, every later stop + repeating it with a bigger number is the same message again. */} +
+
+ {/* Out of fuel is a thing that happens to the DRIVE, so the mascot is the one + with the vehicle, and it is not enjoying it. */} + +
+
+ {t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')} +
+ {/* How far INTO this leg, where a drive band keeps its figures. Not the + range that was crossed: that is the traveller's own setting, says + nothing about where, and made every band on a day read the same + number. */} +
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} +
+
+ {/* The low-fuel lamp IS the button. + It was a lamp beside a magnifier, which is the same thing said twice: the + lamp says the tank is empty and the magnifier offers to do something + about it, and nobody reads a dashboard warning as decoration. So the lamp + glows, and pressing it goes looking. Larger than the 18px marks a drive + band carries, because unlike those it is the one thing in the band worth + pressing. What it does is in the tooltip and in the screen reader label. + + Once a search is open the lamp steps aside for the state that matters: + the way to close the answer, or the way to ask again. An answer that + found nothing leaves a button rather than a dead end — the place search + is a shared public service that does time out, and "it did not answer" + with no way to retry reads as broken rather than as busy. */} {open && (refuel.loading || refuel.results.length) ? ( ) : ( - /* An answer that found nothing leaves the button, not a dead end. The place - search is a shared public service that does time out, and "it did not - answer" with no way to ask again reads as broken rather than as busy. */ - + )} @@ -479,31 +555,94 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { {settled ? ( refuel.results.length ? ( -
    +
      {/* Three at most. This is an offer beside a plan, not a list to browse; - the corridor panel is where somebody goes to see all of them. */} - {refuel.results.slice(0, 3).map(poi => ( -
    • - - {poi.name} - - - {/* What is left when the car draws level, the figure that decides - whether this one is any use. The detour is already in it. */} - {t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })} - - {onAccept ? ( - - ) : null} -
    • - ))} + the corridor panel is where somebody goes to see all of them. + + Each one on its own surface rather than as bare text in the band: the + band is the problem and these are the answers, and a row somebody is + meant to press has to look like it can be. Two lines, because one was + not enough to tell them apart — measured on a real day the top three + came back as "Vattenfall InCharge" three times over, identical but for + a number nobody could see the meaning of. */} + {refuel.results.slice(0, 3).map(poi => { + const kind = STOP_KIND_BY_KEY[poi.category] + const KindIcon = kind?.Icon ?? Fuel + return ( +
    • + {/* Logical padding, not left/right: the rail runs the other way in Arabic and + the disc has to keep its distance from the reading edge either way. */} +
      + + + + + + {poi.name} + + {/* The two figures that decide it, in the order they decide it, + and as two badges rather than one line with a middot in it. + No value in this rail is ever joined to another that way: + they are separate facts, and a separator invites them to be + read as one number about one thing. + + The detour is what the list is SORTED by — everything here + is reachable already, so what separates them is what the + stop costs — and it is also the only thing telling three + branches of one chain apart. What is left in the tank when + the car draws level comes second, with the detour already + counted into it. The badge carries the figure and the + tooltip says which one it is, which is how two numbers stay + legible in a column this narrow. */} + + + + + + + + {formatDistance(poi.offRouteKm, distanceUnit)} + + + + + + + + + + {formatDistance(Math.round(poi.spareKm), distanceUnit)} + + + + + + {onAccept ? ( + + + + ) : null} +
      +
    • + ) + })}
    ) : ( /* Three different sentences for three different facts. "Nothing on this @@ -518,7 +657,6 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: { ) ) : null} -
) @@ -809,53 +947,6 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea ) } -/** - * Midnight, marked where it happens — in the chain, between the two stops the drive runs - * between, rather than as a fourth badge hanging off one of them. - * - * The schedule has carried this since it was written and nothing ever drew it: a day - * running past midnight showed "02:30" as if it were tonight. - */ -/** - * Where the chain crosses midnight. - * - * The one band in the rail that breaks the line instead of hanging off it, and the only - * thing here that moves: a night is not a place and not a drive, it is the day running - * out, and the rail should look like it stops. So it spans the marker column too, the - * dashes end above it and start again below, and the mascot dozes in the gap with three - * z's drifting off it. - * - * The ground is mixed rather than the soft info token: that token is translucent in the - * dark theme, and the mascot cuts its eyes out in the colour behind it, which would show - * the body through them. Mixing against the card keeps it opaque in both themes. - */ -function OvernightBreak(): React.ReactElement { - const { t } = useTranslation() - const night = 'color-mix(in srgb, var(--info) 13%, var(--bg-card))' - return ( -
- - - {t('roadtrip.warn.overnight')} - - {/* Three z's on one baseline, each starting a third of the loop after the last, so - one is always on its way up. Purely decorative: the word beside it already says - what this band is. */} - - z - z - z - -
- ) -} - /** * The arrival — the one value in the rail the user may have decided themselves. * @@ -1064,15 +1155,150 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React ) } +/** + * What a night drive leaves on the next morning's card. + * + * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 + * and arriving at 01:23 arrives tomorrow, and the rail draws it there — under tomorrow's + * date, with the night it came through kept in front of it so the kilometres reach the + * stops rather than being left on a day nobody drives them on. + * + * Nothing has been written to say so. The stops still belong to the day they were planned + * on and the day plan still shows them there; this is the arrangement the arrival times + * imply, worked out again every time they change. Shorten the stay before the night drive + * and the first stop crosses back on its own. + * + * The ground fades from night at the head to the card's own surface at the foot, because + * that is what the block is: the end of one day handed to the beginning of the next. + */ +function SpillBlock({ spill, children }: { + spill: SpillMark + children: React.ReactNode +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const leg = spill.leg + const departure = spill.departure + // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes + // out in the colour behind it, and a translucent ground would show its body through + // them. The same reason the old overnight band mixed instead of tinting. + const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' + const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' + const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' + return ( +
  • + {/* Moonlight from the left, falling on the mascot. A radial wash rather than a + second background colour: it has no edge of its own, so the head reads as lit + rather than as another box stacked on the first. */} +
    +
    + + + {t('roadtrip.spill.title', { number: spill.fromDayNumber })} + + {/* Three z's on one baseline, each starting a third of the loop after the last, + so one is always on its way up. Decorative: the line beside it already says + what the block is. */} + + z + z + z + +
    + {/* The drive itself, in the band the rail uses everywhere else — this is the + reason the block exists, and the kilometres on it are the ones the card's + header now counts. Not clickable: alternatives are asked for on the day the + leg is stored on, and offering the same leg twice would be two answers. */} +
    + + + +
    + {/* Shaped like every other drive in the rail: what it cost on the left, the + clock on the right, in the column every arrival in this card already + reads down. The time used to sit inside the sentence, which put a + reading in the one place the eye does not look for one — and gave the + row two competing figures with no order between them. */} +
    + + + {leg + ? t('roadtrip.leg.driveText', { + distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), + time: formatDurationShort(leg.duration ?? 0), + }) + : t('roadtrip.leg.pending')} + + {/* Departure, not arrival, so it says so in words: everything else in this + column is a time of arrival, and a bare 23:57 there would read as one. + The stop it leaves from is not named — it is drawn on yesterday's card + directly above, and a second row for it would read as a stop made + twice. */} + {departure ? ( + + {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} + + ) : null} +
    +
    +
    +
    + {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the + inside edge lands one pixel in unless the padding gives that pixel back. It is + the difference between the numbers in here standing on the same line as the ones + below and standing almost on it. */} +
      {children}
    +
  • + ) +} + /** * One day, one card: its own numbers in its own head, its stops chained below. * * Stops count from one inside the day because that is what the map badges on its markers * (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across * the trip would put "17" beside a pin the map calls "3". + * + * A card is no longer one stored day. It is one DATE, and it draws whatever is reached on + * it — which on the morning after a night drive means a stretch that is stored on + * yesterday. Every stop keeps the day it is stored on in `block.source`, so a reorder, a + * move, a stay edit or a refuel offer still names the day the server knows it by. See + * `nightSpill.ts`. */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle }: { day: RoadtripDay + /** Folded down to the header, and off the map with it. */ + collapsed?: boolean + onToggle?: () => void selectedAssignmentId?: number | null onSelectStop?: (placeId: number, assignmentId: number) => void onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void @@ -1094,13 +1320,16 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on loading?: boolean }): React.ReactElement { const { from, setFrom, dropAt, setDropAt } = drag - const dragging = from?.dayId === day.dayId ? from.index : null // Which disc the picker hangs under, and for which stop. One at a time: two open // popovers over the same rail is two answers to one question. const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null) const { t, language } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // The colour the map draws this day in, or none at all while the map is drawing one + // blue line for the whole trip. + const dayColorsOn = useSettingsStore(s => !!s.settings.roadtrip_day_colors) + const tint = dayColorsOn ? dayColor(day.dayNumber).line : null const last = day.stops.length - 1 /** * The findings a stop wears, minus the one the refuel band already says better. @@ -1110,12 +1339,147 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on * at once says the same thing twice, in the wrong order — the answer above the problem * — so the warning stands down wherever the band is showing. */ - const showingBand = !!refuel && !loading && !!day.dryPoints?.length + const showingBand = !!refuel && !loading const findingsFor = (i: number) => day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range')) // The running number a stop wears, with the service stops passed over — so a day with a // charger halfway through still counts one, two, three the way its map pins do. let counted = 0 + + /** + * One row of the chain: the stop, the drive leaving it, whatever hangs off that drive. + * + * The day's own index `i` addresses everything that belongs to this DATE — the leg, the + * schedule entry, the dry points. Everything that WRITES uses `stop.ownerDayId` and + * `stop.ownerIndex` instead, because a stop driven onto this date through the night is + * still stored on the day it set off from (`nightSpill.ts`). + */ + const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => { + const service = isServiceStopType(stop.stopType) + if (!service) counted += 1 + // Every finding at this index is read on its own. Taking the first match let an + // overnight crossing swallow the "you arrive late" flag without a trace. + const marks = day.schedule.warnings.filter(w => w.index === i) + const ownDay = stop.ownerDayId + const ownIndex = stop.ownerIndex + return ( +
  • { + setFrom({ dayId: ownDay, index: ownIndex, assignmentId: stop.assignmentId }) + e.dataTransfer.effectAllowed = 'move' + // Firefox refuses to start a drag without payload, even an unused one. + e.dataTransfer.setData('text/plain', String(stop.assignmentId)) + } : undefined} + onDragEnd={() => { setFrom(null); setDropAt(null) }} + onDragOver={onReorderStop && from ? e => { + e.preventDefault() + if (dropAt?.dayId !== ownDay || dropAt.index !== ownIndex) setDropAt({ dayId: ownDay, index: ownIndex }) + } : undefined} + onDrop={onReorderStop && from ? e => { + e.preventDefault() + const src = from + setFrom(null) + setDropAt(null) + if (src.dayId === ownDay) { + if (src.index !== ownIndex) onReorderStop(ownDay, src.assignmentId, ownIndex) + } else { + onMoveStopToDay?.(src.dayId, src.assignmentId, ownDay, ownIndex) + } + } : undefined} + className={`rounded-lg transition-opacity ${from?.dayId === ownDay && from.index === ownIndex ? 'opacity-40' : ''} ${ + dropAt?.dayId === ownDay && dropAt.index === ownIndex && from && !(from.dayId === ownDay && from.index === ownIndex) + ? 'ring-2 ring-inset ring-accent' + : '' + }`} + > + {service ? ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + ) : ( + w.code === 'late')} + driveFindings={findingsFor(i)} + selected={selectedAssignmentId === stop.assignmentId} + continues={i < last} + starts={i === 0} + onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined} + onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined} + canMove={{ up: i > 0, down: i < last }} + onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined} + onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined} + onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined} + /> + )} + {i < last ? ( + onAskAlternatives(day.dayId, i) : undefined} + alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} + /> + ) : null} + {/* Only on the leg the fuel actually runs out on, and only once the day has + finished routing: the warnings are republished after every routing task + and the early ones are wrong, so an offer that appears and moves while + the trip loads reads as a fault. */} + {refuel && !loading + ? (day.dryPoints ?? []) + .filter(dry => dry.legIndex === i) + .map(dry => ( + onAskRefuel?.(day.dayId, dry)} + onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} + /> + )) + : null} + {/* After the band, because a plugin halt happens on the drive it describes + rather than before setting off. */} + {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( + + )) : null} +
  • + ) + } + + /** + * The chain in runs: the stretches driven onto this date through the night, and the + * day's own stops between and after them. + * + * Built rather than mapped straight through, because a spill is drawn inside a block of + * its own and a block cannot be opened halfway down a `map`. + */ + const runs: { spill: SpillMark | null; from: number; to: number }[] = [] + { + let at = 0 + for (const spill of day.spills ?? []) { + if (spill.at > at) runs.push({ spill: null, from: at, to: spill.at }) + runs.push({ spill, from: spill.at, to: spill.at + spill.count }) + at = spill.at + spill.count + } + if (at < day.stops.length) runs.push({ spill: null, from: at, to: day.stops.length }) + } + return (
    {/* Centred, with the day's facts as badges beneath its name: at this width a row @@ -1125,7 +1489,38 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on and the list below holds its stops, and `--bg-hover` at 40% is 1% black in light mode — a separation nobody could see. `--bg-secondary` lifts off the card in both themes while staying a step under the badges sitting on it. */} -
    +
    { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } + }, + } : {})} + // The margin and the bottom rule belong to the header only while there is a list + // under them. Folded, they left a white strip of card below the tinted header — + // and once the day is tinted, that strip is the one thing on the card that is not. + className={`border-edge-faint bg-surface-secondary px-3.5 pb-2.5 pt-3 ${collapsed ? '' : 'mb-1 border-b'} ${ + onToggle ? 'cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent' : '' + }`} + // Washed with the colour this day is drawn in, and only while the map is drawing + // days in colour: on the map the colour is what tells one day from the next, and + // a rail that ignored it would leave the reader matching a green line to a card + // that gives no sign of being the green one. A tenth of the hue is enough to + // recognise and quiet enough that the day's own facts still read first. + style={tint ? { + backgroundImage: `linear-gradient(180deg, color-mix(in srgb, ${tint} 13%, var(--bg-secondary)), color-mix(in srgb, ${tint} 5%, var(--bg-secondary)))`, + borderBottomColor: `color-mix(in srgb, ${tint} 30%, transparent)`, + } : undefined} + >

    {day.date ? ( -

    {sp.role === 'cover' ? t('journey.studio.cover') : sp.role === 'back' ? t('journey.studio.backCover') - : `${i * 2} – ${i * 2 + 1}`} + : folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))} diff --git a/client/src/components/Studio/autoLayout.ts b/client/src/components/Studio/autoLayout.ts index ea76c7a99..66f3d78b0 100644 --- a/client/src/components/Studio/autoLayout.ts +++ b/client/src/components/Studio/autoLayout.ts @@ -1531,7 +1531,7 @@ function backSpread(input: AutoInput): BookSpread { } export function buildBook(input: AutoInput): BookDocument { - const spreads: BookSpread[] = [coverSpread(input)] + const spreads: BookSpread[] = [coverSpread(input), blankSpread('first')] /* * The summary and the countries open the book, before the entries. @@ -1572,15 +1572,41 @@ export function buildBook(input: AutoInput): BookDocument { } } flushRun() - spreads.push(backSpread(input)) + spreads.push(blankSpread('last'), backSpread(input)) return { version: 1, title: input.title.slice(0, MAX_BOOK_TITLE), - page: input.page, + page: withFirstPage(input.page), spreads: spreads.slice(0, MAX_SPREADS), } } +/** + * A page with nothing on it, of the given kind. + * + * The first and the last page of a book are laid down empty on purpose: the + * first is where a title page or a dedication goes, the last where a colophon + * does, and neither is something a layout should decide for a person. + */ +function blankSpread(role: BookSpread['role']): BookSpread { + return { id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null } +} + +/** + * The page setup for a book that opens on a single page. + * + * `startAt` numbers the first page inside the covers. A book from before the + * single first page existed opened straight onto a spread and started at 2, + * so that its left-hand page read as the second page of the object. With a + * first page in front of that spread the first page is page 1 — so a book laid + * out with one starts there, whatever the setup it inherited said (#2317). + * Nothing else of the setup is touched: the format and the folio styling are + * the person's, only the count moved with the page that moved. + */ +function withFirstPage(page: BookPageSetup): BookPageSetup { + return { ...page, pageNumbers: { ...page.pageNumbers, startAt: 1 } } +} + /** * A book nobody has laid out yet. * @@ -1592,17 +1618,15 @@ export function buildBook(input: AutoInput): BookDocument { * * The covers are here rather than left out because a book has them and there is * no other way to add one: the pages rail only inserts inner spreads, between - * the two. Empty, though, in the same way the page between them is. + * the two. The single first and last pages likewise (#2317). Empty, though, in + * the same way the spread between them is. */ export function emptyBook(input: AutoInput): BookDocument { - const blank = (role: BookSpread['role']): BookSpread => ({ - id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null, - }) return { version: 1, title: input.title.slice(0, MAX_BOOK_TITLE), - page: input.page, - spreads: [blank('cover'), blank('inner'), blank('back')], + page: withFirstPage(input.page), + spreads: [blankSpread('cover'), blankSpread('first'), blankSpread('inner'), blankSpread('last'), blankSpread('back')], } } diff --git a/client/src/components/Studio/bookSheets.ts b/client/src/components/Studio/bookSheets.ts index dcb23f6a3..732887565 100644 --- a/client/src/components/Studio/bookSheets.ts +++ b/client/src/components/Studio/bookSheets.ts @@ -110,6 +110,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] { doc.spreads.forEach((spread, spreadIndex) => { const single = spread.role !== 'inner' const spreadWidth = single ? pageWidth : pageWidth * 2 + const folios = foliosOf(doc.spreads, spreadIndex, pageNumbers.startAt) if (single || mode === 'spreads') { out.push({ @@ -120,7 +121,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] { height: pageHeight, spreadWidth, single, - label: single ? '' : folioRange(spreadIndex, pageNumbers.startAt), + label: folioLabel(folios), }) return; } @@ -134,7 +135,7 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] { height: pageHeight, spreadWidth, single: true, - label: String(folio(spreadIndex, pageNumbers.startAt) + half), + label: String(folios[half]), }) } }) @@ -142,18 +143,40 @@ export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] { return out } -/** - * The number the left-hand page of a spread carries. - * - * Mirrors PageNumbers.tsx: the first inner spread opens at `startAt`, and the - * cover is a separate sheet, which is why the index is offset rather than used - * raw. It is only ever a label here — nothing is printed from it. - */ -function folio(spreadIndex: number, startAt: number): number { - return startAt + (spreadIndex - 1) * 2 +/** Which of a book's leaves carry a page number: everything inside the covers. */ +export function isNumbered(role: BookSpread['role']): boolean { + return role !== 'cover' && role !== 'back' } -function folioRange(spreadIndex: number, startAt: number): string { - const left = folio(spreadIndex, startAt) - return `${left} – ${left + 1}` +/** + * The page numbers a spread carries, in reading order. + * + * Counted along the book rather than from the index: the covers carry none, + * a first or last page carries one, an inner spread two, and the first + * numbered page is `startAt`. A book from before the single first page + * existed has `startAt` 2 and no such page, so its first spread still reads + * 2 – 3; a book laid out since has a first page and starts at 1, so that page + * is 1 and the same spread behind it is 2 – 3 (#2317). Either way the number + * follows what is in the book, not where the spread sits in the array. + * + * The renderer and the print labels both come through here, so a number seen + * on screen is the one that is printed. + * + * Empty for a cover, and for an index past the end. + */ +export function foliosOf(spreads: readonly BookSpread[], spreadIndex: number, startAt: number): number[] { + const spread = spreads[spreadIndex] + if (!spread || !isNumbered(spread.role)) return [] + let next = startAt + for (let i = 0; i < spreadIndex; i++) { + const role = spreads[i].role + if (!isNumbered(role)) continue + next += role === 'inner' ? 2 : 1 + } + return spread.role === 'inner' ? [next, next + 1] : [next] +} + +/** "3", or "2 – 3" for a spread; nothing for a cover. */ +export function folioLabel(folios: readonly number[]): string { + return folios.length === 2 ? `${folios[0]} – ${folios[1]}` : folios.length === 1 ? String(folios[0]) : '' } diff --git a/client/src/store/studioStore.ts b/client/src/store/studioStore.ts index 0c9365119..b4b4a6dda 100644 --- a/client/src/store/studioStore.ts +++ b/client/src/store/studioStore.ts @@ -77,6 +77,34 @@ interface StudioState { canRedo: () => boolean } +/** + * Where an inner spread may go: after the cover and the first page, before + * the last page and the back cover. + * + * Read from the document each time rather than assumed, because a book from + * before the single first page existed has none, and a book with no covers at + * all (the tests build those) has nothing to stay inside of. The lower bound + * is not decoration either: a book with no inner spreads yet reports its last + * inner one as -1, which asked for a page at 0 — in front of the cover, where + * it could not be moved back from, because a move only ever swaps with + * another inner spread. + */ +function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } { + let first = 0 + for (let i = 0; i < spreads.length; i++) { + const role = spreads[i].role + if (role === 'cover' || role === 'first') first = i + 1 + else break + } + let limit = spreads.length + for (let i = spreads.length - 1; i >= 0; i--) { + const role = spreads[i].role + if (role === 'back' || role === 'last') limit = i + else break + } + return { first, limit: Math.max(first, limit) } +} + function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument { const spreads = doc.spreads.slice() if (!spreads[index]) return doc @@ -197,10 +225,14 @@ export const useStudioStore = create((set, get) => ({ * * The cover and the back cover are fixed points: a book has exactly one of * each, they are single pages rather than spreads, and the layouts panel - * already refuses to touch them. So everything here operates on the inner - * spreads between them, and `canEditSpread` is the one place that decides - * what counts as inner — the rail, the menu and the store all ask it rather - * than each testing `role` for themselves. + * already refuses to touch them. The single first and last pages inside + * them are fixed the same way (#2317): a bound book opens onto one + * right-hand page and closes on one left-hand page, and moving either into + * the middle of the book would put a half-width page between two spreads. + * So everything here operates on the inner spreads between them, and + * `canEditSpread` is the one place that decides what counts as inner — the + * rail, the menu and the store all ask it rather than each testing `role` + * for themselves. */ canEditSpread: index => { const sp = get().doc?.spreads[index] @@ -219,10 +251,7 @@ export const useStudioStore = create((set, get) => ({ * move only ever swaps with another inner spread. The first page of an * empty book is the one this catches. */ - const cover = doc.spreads.findIndex(sp => sp.role === 'cover') - const back = doc.spreads.findIndex(sp => sp.role === 'back') - const first = cover === -1 ? 0 : cover + 1 - const limit = back === -1 ? doc.spreads.length : back + const { first, limit } = innerBounds(doc.spreads) const at = Math.min(Math.max(index + 1, first), limit) get().commit(d => ({ ...d, @@ -246,10 +275,7 @@ export const useStudioStore = create((set, get) => ({ const doc = get().doc if (!doc) return // Same bounds as addSpread: inside the covers at both ends. - const cover = doc.spreads.findIndex(sp => sp.role === 'cover') - const back = doc.spreads.findIndex(sp => sp.role === 'back') - const first = cover === -1 ? 0 : cover + 1 - const limit = back === -1 ? doc.spreads.length : back + const { first, limit } = innerBounds(doc.spreads) const at = Math.min(Math.max(index + 1, first), limit) get().commit(d => ({ ...d, diff --git a/client/tests/unit/studio/autoLayout.stations.test.ts b/client/tests/unit/studio/autoLayout.stations.test.ts index 66a822a89..3e4aa0826 100644 --- a/client/tests/unit/studio/autoLayout.stations.test.ts +++ b/client/tests/unit/studio/autoLayout.stations.test.ts @@ -178,9 +178,9 @@ describe('an entry with words but no pictures', () => { }) describe('a journey with nothing at all', () => { - it('produces a book of a cover and a back, and no blank pages between', () => { + it('produces a book of the covers and the single pages inside them, and no spreads between', () => { const spreads = build([]) expect(inner(spreads)).toHaveLength(0) - expect(spreads.map(s => s.role)).toEqual(['cover', 'back']) + expect(spreads.map(s => s.role)).toEqual(['cover', 'first', 'last', 'back']) }) }) diff --git a/client/tests/unit/studio/autoLayout.travel.test.ts b/client/tests/unit/studio/autoLayout.travel.test.ts index 194d6c20e..6f04af806 100644 --- a/client/tests/unit/studio/autoLayout.travel.test.ts +++ b/client/tests/unit/studio/autoLayout.travel.test.ts @@ -83,10 +83,11 @@ describe('the summary spread', () => { expect(has(doc, 'stats')).toBe(true) }) - it('comes straight after the cover, before the entries', () => { + it('comes straight after the cover and the first page, before the entries', () => { const doc = build() expect(doc.spreads[0].role).toBe('cover') - expect(kinds(doc.spreads[1])).toContain('map') + expect(doc.spreads[1].role).toBe('first') + expect(kinds(doc.spreads[2])).toContain('map') }) it('carries the journey figures rather than placeholders', () => { @@ -125,8 +126,8 @@ describe('the summary spread', () => { points: [], distance: 0, days: 0, steps: 0, photos: 0, places: 0, furthest: 0, countries: [], }), }) - // Cover, the one entry, back cover — no summary, no country page. - expect(doc.spreads.map(sp => sp.role)).toEqual(['cover', 'inner', 'back']) + // Cover, first page, the one entry, last page, back cover — no summary, no country page. + expect(doc.spreads.map(sp => sp.role)).toEqual(['cover', 'first', 'inner', 'last', 'back']) expect(has(doc, 'map')).toBe(false) expect(has(doc, 'stats')).toBe(false) }) @@ -136,8 +137,8 @@ describe('the summary spread', () => { expect(has(doc, 'map')).toBe(false) expect(has(doc, 'stats')).toBe(false) expect(has(doc, 'countries')).toBe(false) - // And the book still builds: cover, the entry, back cover. - expect(doc.spreads).toHaveLength(3) + // And the book still builds: cover, first page, the entry, last page, back cover. + expect(doc.spreads).toHaveLength(5) }) }) @@ -175,7 +176,7 @@ describe('the country page', () => { it('sits between the summary and the entries', () => { const doc = build({ stats: twoCountries }) const order = doc.spreads.map(sp => kinds(sp).find(k => k === 'map' || k === 'countries') ?? sp.role) - expect(order.slice(0, 3)).toEqual(['cover', 'map', 'countries']) + expect(order.slice(0, 4)).toEqual(['cover', 'first', 'map', 'countries']) }) }) @@ -313,13 +314,37 @@ describe('an empty book', () => { stats: stats(), } - it('has a cover, a page and a back cover, and nothing on any of them', () => { + it('has a cover, a first page, a spread, a last page and a back cover, and nothing on any of them', () => { const doc = emptyBook(input) - expect(doc.spreads.map(s => s.role)).toEqual(['cover', 'inner', 'back']) + expect(doc.spreads.map(s => s.role)).toEqual(['cover', 'first', 'inner', 'last', 'back']) expect(doc.spreads.every(s => s.elements.length === 0)).toBe(true) expect(doc.spreads.every(s => s.background === null)).toBe(true) }) + /* + * A bound book opens onto page 1, the single right-hand page behind the + * cover (#2317). The default setup counts from 2, which was right while the + * book opened straight onto a spread; a book laid down with a first page + * counts from that page instead, whatever setup it was handed. + */ + it('numbers its first page 1, whatever the setup said', () => { + expect(input.page.pageNumbers.startAt).toBe(2) + expect(emptyBook(input).page.pageNumbers.startAt).toBe(1) + expect(buildBook(input).page.pageNumbers.startAt).toBe(1) + // Nothing else of the setup moves. + expect(emptyBook(input).page.pageNumbers.position).toBe(input.page.pageNumbers.position) + }) + + it('lays the first and last pages down empty in a laid-out book too', () => { + const doc = buildBook(input) + const first = doc.spreads.find(s => s.role === 'first')! + const last = doc.spreads.find(s => s.role === 'last')! + expect(doc.spreads.indexOf(first)).toBe(1) + expect(doc.spreads.indexOf(last)).toBe(doc.spreads.length - 2) + expect(first.elements).toEqual([]) + expect(last.elements).toEqual([]) + }) + it('carries the journey title and the page setup it was given', () => { const doc = emptyBook(input) expect(doc.title).toBe('Iceland') diff --git a/client/tests/unit/studio/bookSheets.test.ts b/client/tests/unit/studio/bookSheets.test.ts index 10ea8ae72..ec8f5e6cb 100644 --- a/client/tests/unit/studio/bookSheets.test.ts +++ b/client/tests/unit/studio/bookSheets.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import type { BookDocument, BookSpread } from '@trek/shared' import { bookPageSetupSchema } from '@trek/shared' -import { MARK_LENGTH, sheetBox, sheetsFor } from '../../../src/components/Studio/bookSheets' +import { MARK_LENGTH, folioLabel, foliosOf, isNumbered, sheetBox, sheetsFor } from '../../../src/components/Studio/bookSheets' /** * Cutting a book into sheets (#1973). @@ -117,3 +117,75 @@ describe('an empty book', () => { expect(sheetsFor(book([]), 'pages')).toEqual([]) }) }) + +/* + * A bound book opens onto one right-hand page and closes on one left-hand + * page (#2317). They are single leaves like the covers, but unlike the covers + * they are numbered — and the numbers count along the book, so a book with + * them and a book from before them both read right. + */ +describe('the first and last pages', () => { + it('stay whole in both modes, like the covers', () => { + const roles: BookSpread['role'][] = ['cover', 'first', 'inner', 'last', 'back'] + for (const mode of ['pages', 'spreads'] as const) { + const sheets = sheetsFor(book(roles), mode) + const firstSheet = sheets.find(s => s.spread.role === 'first')! + const lastSheet = sheets.find(s => s.spread.role === 'last')! + expect(firstSheet.single, mode).toBe(true) + expect(firstSheet.width, mode).toBe(210) + expect(lastSheet.single, mode).toBe(true) + expect(lastSheet.width, mode).toBe(210) + } + }) + + it('are numbered, counting from startAt along the book', () => { + const roles: BookSpread['role'][] = ['cover', 'first', 'inner', 'inner', 'last', 'back'] + const sheets = sheetsFor(book(roles, { pageNumbers: { startAt: 1 } }), 'pages') + expect(sheets.map(s => s.label)).toEqual(['', '1', '2', '3', '4', '5', '6', '']) + expect(sheetsFor(book(roles, { pageNumbers: { startAt: 1 } }), 'spreads').map(s => s.label)) + .toEqual(['', '1', '2 – 3', '4 – 5', '6', '']) + }) + + it('leave a book without them on the numbers it always had', () => { + // startAt 2, no first page: the first spread still opens on 2 – 3. + const sheets = sheetsFor(book(['cover', 'inner', 'inner', 'back']), 'spreads') + expect(sheets.map(s => s.label)).toEqual(['', '2 – 3', '4 – 5', '']) + }) +}) + +describe('foliosOf', () => { + const roles: BookSpread['role'][] = ['cover', 'first', 'inner', 'inner', 'last', 'back'] + const spreads = book(roles).spreads + + it('hands a cover nothing, a single page one number and a spread two', () => { + expect(foliosOf(spreads, 0, 1)).toEqual([]) + expect(foliosOf(spreads, 1, 1)).toEqual([1]) + expect(foliosOf(spreads, 2, 1)).toEqual([2, 3]) + expect(foliosOf(spreads, 3, 1)).toEqual([4, 5]) + expect(foliosOf(spreads, 4, 1)).toEqual([6]) + expect(foliosOf(spreads, 5, 1)).toEqual([]) + }) + + it('starts wherever the book says', () => { + expect(foliosOf(spreads, 1, 3)).toEqual([3]) + expect(foliosOf(spreads, 2, 3)).toEqual([4, 5]) + }) + + it('is empty past the end of the book', () => { + expect(foliosOf(spreads, 99, 1)).toEqual([]) + }) + + it('numbers everything inside the covers', () => { + expect(isNumbered('cover')).toBe(false) + expect(isNumbered('back')).toBe(false) + expect(isNumbered('first')).toBe(true) + expect(isNumbered('inner')).toBe(true) + expect(isNumbered('last')).toBe(true) + }) + + it('labels a pair with a dash, one number plainly, and nothing as nothing', () => { + expect(folioLabel([2, 3])).toBe('2 – 3') + expect(folioLabel([7])).toBe('7') + expect(folioLabel([])).toBe('') + }) +}) diff --git a/client/tests/unit/studio/pageSetup.test.tsx b/client/tests/unit/studio/pageSetup.test.tsx index d22914b65..9c8e7c5df 100644 --- a/client/tests/unit/studio/pageSetup.test.tsx +++ b/client/tests/unit/studio/pageSetup.test.tsx @@ -56,48 +56,62 @@ describe('a custom trim size', () => { describe('page numbers', () => { it('are off until asked for', () => { expect(page().pageNumbers.show).toBe(false) - const { container } = render() + const { container } = render() expect(container.textContent).toBe('') }) it('number both pages of a spread, left then right', () => { const { container } = render( - , + , ) expect(container.textContent).toBe('23') }) /* - * The first inner spread opens on `startAt`, and the cover is a separate - * sheet — which is why the index has to be offset rather than used raw. + * The numbers come from the caller (foliosOf, tested with bookSheets): the + * sheet draws whatever it is handed, so a spread deep in the book reads + * what the book says it is. */ - it('advance by two for each spread further into the book', () => { + it('draw the numbers they are handed, wherever the spread sits', () => { const p = page({ pageNumbers: { show: true } }) - const third = render() + const third = render() expect(third.container.textContent).toBe('67') }) - it('start where the book says, since a binder may or may not count the cover', () => { - const p = page({ pageNumbers: { show: true, startAt: 1 } }) - const { container } = render() - expect(container.textContent).toBe('12') - }) - /* A folio on a cover is a mistake in every book ever bound. */ it('never appear on the cover or the back cover', () => { const p = page({ pageNumbers: { show: true } }) for (const role of ['cover', 'back'] as const) { - const { container } = render() + const { container } = render() expect(container.textContent, role).toBe('') } }) + /* The single first page is a right-hand leaf and the last a left-hand one (#2317). */ + it('put one number on a first or last page, on the side it is bound', () => { + const p = page({ pageNumbers: { show: true, position: 'outer' } }) + const first = render() + const last = render() + expect(first.container.textContent).toBe('1') + expect(last.container.textContent).toBe('9') + const box = (c: HTMLElement) => (c.firstElementChild!.children[0]) as HTMLElement + // Outer on a right-hand page is the right edge; on a left-hand page the left. + expect(box(first.container).style.textAlign).toBe('right') + expect(box(last.container).style.textAlign).toBe('left') + }) + + it('draw nothing for a numbered page handed no numbers', () => { + const p = page({ pageNumbers: { show: true } }) + const { container } = render() + expect(container.textContent).toBe('') + }) + it('sit against the cut edge on outer, and in the gutter on inner', () => { const outer = render( - , + , ) const inner = render( - , + , ) const leftOf = (c: HTMLElement) => parseFloat(((c.firstElementChild!.children[0]) as HTMLElement).style.left) diff --git a/client/tests/unit/studio/spreadFixes.test.tsx b/client/tests/unit/studio/spreadFixes.test.tsx index 7592ef41b..04d07a9c1 100644 --- a/client/tests/unit/studio/spreadFixes.test.tsx +++ b/client/tests/unit/studio/spreadFixes.test.tsx @@ -38,7 +38,7 @@ describe('an empty frame', () => { */ it('wears the frame it will wear once it is filled', () => { const { container } = render( - , + , ) const outer = container.querySelector('[style*="left: 20mm"]')! // A Polaroid's white card and the shadow of a print lying on a page. @@ -48,7 +48,7 @@ describe('an empty frame', () => { it('shows a film frame as film, black card and all', () => { const { container } = render( - , + , ) const outer = container.querySelector('[style*="left: 20mm"]')! expect(outer.style.background).toContain('rgb(20, 20, 20)') @@ -56,7 +56,7 @@ describe('an empty frame', () => { it('leaves a plain frame plain', () => { const { container } = render( - , + , ) const outer = container.querySelector('[style*="left: 20mm"]')! expect(outer.style.boxShadow).toBe('') @@ -65,7 +65,7 @@ describe('an empty frame', () => { /* In the bound book an unfilled frame is nothing at all. */ it('prints as nothing', () => { const { container } = render( - , + , ) expect(container.querySelector('[style*="left: 20mm"]')).toBeNull() }) diff --git a/client/tests/unit/studio/studioStore.spreads.test.ts b/client/tests/unit/studio/studioStore.spreads.test.ts index 51e3e1b69..c9bfce87b 100644 --- a/client/tests/unit/studio/studioStore.spreads.test.ts +++ b/client/tests/unit/studio/studioStore.spreads.test.ts @@ -311,3 +311,66 @@ describe('turning an element', () => { expect(rotationOf('one')).toBe(0) }) }) + +/* + * The single first and last pages are fixed the way the covers are (#2317): + * a bound book opens onto one right-hand page and closes on one left-hand + * page, and an inner spread may only ever go between them. + */ +describe('a book with a first and a last page', () => { + beforeEach(() => { + useStudioStore.getState().load(book( + spread('cover', 'cover'), + spread('first', 'first'), + spread('a', 'inner'), + spread('last', 'last'), + spread('back', 'back'), + )) + }) + + it('treats them as fixed points, not as inner spreads', () => { + expect(store().canEditSpread(1)).toBe(false) + expect(store().canEditSpread(3)).toBe(false) + expect(store().canEditSpread(2)).toBe(true) + }) + + it('adds after the first page when asked to add at the cover or the first page', () => { + store().addSpread(0) + expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back']) + expect(store().activeSpread).toBe(2) + }) + + it('never lands on or behind the last page', () => { + store().addSpread(3) + expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back']) + store().addSpread(9) + expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'inner', 'last', 'back']) + }) + + it('inserts a spread inside them too', () => { + store().insertSpread(0, spread('x', 'inner')) + expect(roles()).toEqual(['cover', 'first', 'inner', 'inner', 'last', 'back']) + }) + + it('will not move an inner spread onto either of them', () => { + store().moveSpread(2, -1) + store().moveSpread(2, 1) + expect(ids()).toEqual(['cover', 'first', 'a', 'last', 'back']) + }) + + it('will not move, copy or delete them', () => { + store().moveSpread(1, 1) + store().moveSpread(3, -1) + store().duplicateSpread(1) + store().removeSpread(3) + expect(ids()).toEqual(['cover', 'first', 'a', 'last', 'back']) + }) + + it('adds the first inner spread of an empty book between them', () => { + useStudioStore.getState().load(book( + spread('cover', 'cover'), spread('first', 'first'), spread('last', 'last'), spread('back', 'back'), + )) + store().addSpread(-1) + expect(roles()).toEqual(['cover', 'first', 'inner', 'last', 'back']) + }) +}) diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts index 0de9064be..cc4a58dfe 100644 --- a/shared/src/book/book.schema.ts +++ b/shared/src/book/book.schema.ts @@ -730,8 +730,17 @@ export type BookUnits = z.infer; export const bookSpreadSchema = z.object({ id: z.string().min(1), - /** 'cover' and 'back' are single pages; everything else is a double spread. */ - role: z.enum(['cover', 'back', 'inner']).default('inner'), + /** + * Which leaf of the bound book this is. + * + * 'inner' is a double spread; the other four are single pages. 'cover' and + * 'back' are the outside of the book. 'first' is the right-hand page a book + * opens onto, and 'last' the left-hand page before the back cover: the + * inside of each cover is blank in a bound book, so the first and the last + * inner page stand alone rather than facing anything (#2317). Both are + * optional so that a document from before they existed still reads. + */ + role: z.enum(['cover', 'first', 'inner', 'last', 'back']).default('inner'), background: hex.nullable().default(null), /** Back to front. */ elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]), diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index ab7789402..33a1914bc 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -72,6 +72,7 @@ A journey can also be laid out as a printable photo book. Open a journey and cli ![The TREK Studio editor with the pages rail on the left, a spread on the workbench and the properties inspector on the right](assets/TREK-Studio-Editor.png) - **Page format** — Square 21 × 21 cm (the default), Square 30 × 30 cm, A4 landscape, A4 portrait, A5 landscape, or a custom width × height in millimetres. Everything is drawn as a spread (two pages side by side) with 3 mm bleed and a 5 mm safe margin. +- **The shape of the book** follows a bound one: a cover, a single first page on the right (page 1, where a title page or a dedication goes), the spreads, a single last page on the left, and a back cover. The first and last pages take the cover layouts and, like the covers, stay where they are; new spreads always go between them. Page numbers, when switched on, count from the first page, and the number on screen is the number that prints. Books made before this had no single pages and keep their numbering as it was. - **Auto layout** has two entries. **This spread** builds the spread on screen again from the journal entry it came from, and is only offered on a spread that came from one. **The whole book** replaces every page, keeping your title and page setup. Both are ordinary undo steps, so you can press one, look at it, and undo. - **Pages, Content, Elements, Travel and Layouts** are the sections of the left rail. Content holds the journey's own photos and entries; Layouts has thirteen spread layouts (Hero and story, Four up, Strip and text, Mosaic and so on) plus a separate set of five for the cover and back; Elements has text styles, shapes, lines, grids, empty frames with their frame styles, and a searchable icon library; Travel builds figures out of the journey itself — route maps, country outlines and lists, flags, date, day and distance marks, and a trip summary. - **Properties** on the right edits whatever is selected: position and size, crop and focal point, fill or fit, a look filter, corner radius, frame style, stacking order, lock. An element that auto layout tied to a journal entry follows that entry until you edit it here, which breaks the link. From a8e4c455761f276eb3ab1de1b958561b93b93766 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 11 Sep 2026 11:01:25 +0200 Subject: [PATCH 29/29] feat(share): show a stop and a booking in full on a shared link, and no more (#2320) A public link showed a stop as its name and one line under it, the address or the description, whichever came first. The owner had written both, and a note on the place and another on the day, and had set how long they meant to stay and how to reach it. All of that shows now, laid out the way the planner lays it out, and every link leaves the page in a new tab with no opener. A booking shows the note the owner attached and the page they linked, labelled by where it goes. What travels to the page is chosen column by column now, where it used to be the whole row. The whole row of a booking carried its confirmation number, its import trail and its raw metadata, and the metadata is where an imported ticket keeps its seat and its record locator; the whole row of a place carried the owner's booking notes on it and the Google identifiers. None of that belongs to somebody holding a link. Metadata keeps the parts that describe the journey, by an allow-list, so a field the import starts writing tomorrow does not become public by appearing. Links are accepted only as http or https, on the server and again on the page. --- client/src/pages/SharedTripPage.test.tsx | 98 ++++++++++++ client/src/pages/SharedTripPage.tsx | 21 +-- .../pages/sharedTrip/SharedBookingDetails.tsx | 44 +++++ .../pages/sharedTrip/SharedPlaceDetails.tsx | 103 ++++++++++++ .../pages/sharedTrip/sharedTripModel.test.ts | 37 +++++ .../src/pages/sharedTrip/sharedTripModel.ts | 48 ++++++ server/src/nest/share/share.service.ts | 151 +++++++++++++++++- server/tests/unit/nest/share.service.test.ts | 136 +++++++++++++++- 8 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 client/src/pages/sharedTrip/SharedBookingDetails.tsx create mode 100644 client/src/pages/sharedTrip/SharedPlaceDetails.tsx create mode 100644 client/src/pages/sharedTrip/sharedTripModel.test.ts create mode 100644 client/src/pages/sharedTrip/sharedTripModel.ts diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 6737651f0..68efc409a 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -1352,4 +1352,102 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument()); }); }); + + // ── #2320: the richer read-only detail ───────────────────────────────── + + describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => { + const days = [{ id: 21, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }]; + const assignments = { + 21: [ + { + id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches', + place: { + id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris', + lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null, + duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', + phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [], + }, + }, + ], + }; + + it('renders every field the owner filled, as separate lines', async () => { + await open('detail-token', payload({ days, assignments })); + fireEvent.click(screen.getByText('Day One')); + // The name is also on the map marker's tooltip, so the list row is the one with the address under it. + await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()); + expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0); + + expect(screen.getByText('The big one')).toBeInTheDocument(); + expect(screen.getByText('go early, before the coaches')).toBeInTheDocument(); + expect(screen.getByText('Skip the pyramid queue')).toBeInTheDocument(); + expect(screen.getByText('2 h 30 min')).toBeInTheDocument(); + + const website = screen.getByRole('link', { name: /website/i }); + expect(website).toHaveAttribute('href', 'https://louvre.fr'); + expect(website).toHaveAttribute('target', '_blank'); + expect(website).toHaveAttribute('rel', 'noopener noreferrer'); + + const maps = screen.getByRole('link', { name: /google maps/i }); + expect(maps.getAttribute('href')).toContain('google.com/maps/search/'); + expect(maps.getAttribute('href')).toContain(encodeURIComponent('Louvre, Rue de Rivoli, Paris')); + expect(maps).toHaveAttribute('rel', 'noopener noreferrer'); + + const phone = screen.getByRole('link', { name: '+33 1 40 20 50 50' }); + expect(phone).toHaveAttribute('href', 'tel:+33140205050'); + }); + + it('leaves out what is empty and never renders a link that is not http(s)', async () => { + const bare = { + 21: [{ + id: 302, day_id: 21, order_index: 0, notes: null, + place: { + id: 502, name: 'Somewhere', description: null, address: null, lat: null, lng: null, + duration_minutes: 0, notes: ' ', website: 'javascript:alert(1)', phone: null, + category_id: null, price: null, place_time: null, end_time: null, image_url: null, + transport_mode: 'walking', category: null, tags: [], + }, + }], + }; + await open('bare-token', payload({ days, assignments: bare })); + fireEvent.click(screen.getByText('Day One')); + await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument()); + + expect(screen.queryByRole('link', { name: /website/i })).toBeNull(); + expect(screen.queryByRole('link', { name: /google maps/i })).toBeNull(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + expect(screen.queryByText(/min$/)).toBeNull(); + }); + }); + + describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => { + it('renders the note and a link labelled by its host', async () => { + await open('booking-token', payload({ + permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false }, + reservations: [ + { + id: 91, title: 'Night train', type: 'train', status: 'confirmed', day_id: null, end_day_id: null, + reservation_time: '2026-07-01T21:00:00', reservation_end_time: null, metadata: null, + notes: 'Meet at the north entrance', url: 'https://www.bahn.example/booking/abc', + }, + { + id: 92, title: 'Sketchy', type: 'other', status: 'pending', day_id: null, end_day_id: null, + reservation_time: null, reservation_end_time: null, metadata: null, + notes: null, url: 'javascript:alert(1)', + }, + ], + })); + fireEvent.click(screen.getByText('Bookings')); + await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument()); + + expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'bahn.example' }); + expect(link).toHaveAttribute('href', 'https://www.bahn.example/booking/abc'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + + expect(screen.getByText('Sketchy')).toBeInTheDocument(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 169e5d4db..c9dadaf61 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -32,6 +32,8 @@ import { splitReservationDateTime } from '../utils/formatters'; import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; +import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails'; +import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails'; const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }; @@ -738,7 +740,7 @@ export default function SharedTripPage() { key={`p-${item.data.id}`} style={{ display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: 10, padding: '6px 8px', borderRadius: 6, @@ -773,19 +775,7 @@ export default function SharedTripPage() { > {place.name}
    - {(place.address || place.description) && ( -
    - {place.address || place.description} -
    - )} +
    {place.place_time && (
    )}
    +
    + {note && ( +
    {/* theme-lint-disable — public page, no user theme */} + + {note} +
    + )} + {link && ( +
    + + {linkHost(link)} + + )} +
    + ) +} diff --git a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx new file mode 100644 index 000000000..0526ed858 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx @@ -0,0 +1,103 @@ +import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' +import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' + +/** + * The read-only detail of one stop on a shared plan (#2320). + * + * The page used to show a place as a name and, under it, the address *or* the + * description, one string on one line. The owner had written both, and had + * usually also left a note on the place and another on the day it was + * planned for, and a link would show none of that. This shows what the owner + * chose to share, laid out the way the planner lays it out: address, then + * description, then the two notes, then how long they meant to stay, then + * the ways to reach the place. + * + * Nothing here is editable and nothing here is a route into the app: every + * link leaves the page, in a new tab, with no opener. The server has already + * decided what a public viewer may see; this only renders what arrived. + */ +export interface SharedPlaceLike { + name: string + address?: string | null + description?: string | null + notes?: string | null + lat?: number | null + lng?: number | null + duration_minutes?: number | null + website?: string | null + phone?: string | null + place_time?: string | null + end_time?: string | null +} + +export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPlaceLike; assignmentNotes?: string | null }) { + const { t } = useTranslation() + const website = isHttpUrl(place.website) ? place.website : null + const phone = place.phone?.trim() || null + const mapsUrl = getGoogleMapsUrlForPlace({ + name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, + google_place_id: null, google_ftid: null, + }) + const duration = formatDurationMinutes(place.duration_minutes) + const dayNote = assignmentNotes?.trim() || null + const placeNote = place.notes?.trim() || null + const hasLinks = !!(website || phone || mapsUrl) + + return ( +
    + {place.address && ( +
    {/* theme-lint-disable — public page, no user theme */} + + {place.address} +
    + )} + {place.description && ( +
    {/* theme-lint-disable — public page, no user theme */} + {place.description} +
    + )} + {dayNote && ( +
    {/* theme-lint-disable — public page, no user theme */} + + {dayNote} +
    + )} + {placeNote && ( +
    {/* theme-lint-disable — public page, no user theme */} + + {placeNote} +
    + )} + {(duration || hasLinks) && ( + + )} +
    + ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts new file mode 100644 index 000000000..7858e44f6 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel' + +describe('sharedTripModel (#2320)', () => { + it('accepts http and https and nothing else', () => { + expect(isHttpUrl('https://a.example/x')).toBe(true) + expect(isHttpUrl(' http://a.example ')).toBe(true) + expect(isHttpUrl('javascript:alert(1)')).toBe(false) + expect(isHttpUrl('data:text/html,hi')).toBe(false) + expect(isHttpUrl('ftp://a.example')).toBe(false) + expect(isHttpUrl('not a url')).toBe(false) + expect(isHttpUrl('')).toBe(false) + expect(isHttpUrl(null)).toBe(false) + expect(isHttpUrl(42)).toBe(false) + }) + + it('writes a planned stay as minutes, hours, or both', () => { + expect(formatDurationMinutes(45)).toBe('45 min') + expect(formatDurationMinutes(60)).toBe('1 h') + expect(formatDurationMinutes(150)).toBe('2 h 30 min') + expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') + }) + + it('writes nothing for a missing, zero or nonsense figure', () => { + expect(formatDurationMinutes(null)).toBeNull() + expect(formatDurationMinutes(undefined)).toBeNull() + expect(formatDurationMinutes(0)).toBeNull() + expect(formatDurationMinutes(-5)).toBeNull() + expect(formatDurationMinutes(Number.NaN)).toBeNull() + }) + + it('labels a link by its host, without the www', () => { + expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example') + expect(linkHost('https://booking.example')).toBe('booking.example') + expect(linkHost('nonsense')).toBe('nonsense') + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts new file mode 100644 index 000000000..4eff44662 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -0,0 +1,48 @@ +/** + * Pure helpers for the public shared-trip page (#2320). React-free, so the + * page and its detail blocks can share them and a test can drive them + * without rendering. + */ + +/** + * Whether a string is a link the page may render as one. + * + * The server already drops anything that is not http(s) (share.service.ts), + * so this is the second gate rather than the first — the page must not become + * the place where a `javascript:` value turns into an anchor because a future + * payload forgot to filter it. + */ +export function isHttpUrl(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false + try { + const url = new URL(value.trim()) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +/** + * "45 min", "2 h", "1 h 30 min" — the planned time at a place. + * + * Nothing for a missing, zero or negative figure: the planner's default of an + * hour is a default, not a plan, and the field stores what the owner typed. + */ +export function formatDurationMinutes(minutes: number | null | undefined): string | null { + if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return null + const whole = Math.round(minutes) + const h = Math.floor(whole / 60) + const m = whole % 60 + if (h === 0) return `${m} min` + if (m === 0) return `${h} h` + return `${h} h ${m} min` +} + +/** The hostname a booking link points at, for a label that says where it goes. */ +export function linkHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url + } +} diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 5a266ec6f..9b36e992b 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -51,6 +51,102 @@ export interface ShareTokenInfo { * DatabaseService. Trip access and the 'share_manage' permission gate * create/delete; the shared read is public. */ +/** + * What a place shows the public: where it is, what it is, how to reach it. + * + * Left out on purpose: the owner's booking notes and status on the place, the + * Google identifiers, the routing bookkeeping and the fill figures a road trip + * keeps for itself. `notes` stays — it is the note somebody wrote to be read + * on the plan, and the plan is what a link shares. + */ +const PUBLIC_PLACE_COLUMNS = [ + 'id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', + 'place_time', 'end_time', 'duration_minutes', 'notes', 'image_url', 'website', 'phone', + 'transport_mode', 'created_at', 'updated_at', +].map(c => `p.${c}`).join(', '); + +/** + * What a booking shows the public: when, where, what kind, and the note and + * link the owner attached to it. Never the confirmation number, never the + * import trail, never who is travelling on it. + */ +const PUBLIC_RESERVATION_COLUMNS = [ + 'id', 'trip_id', 'day_id', 'end_day_id', 'place_id', 'accommodation_id', 'title', 'type', 'status', 'location', + 'reservation_time', 'reservation_end_time', 'notes', 'url', 'metadata', 'created_at', +].map(c => `r.${c}`).join(', '); + +/** A stay: which place, which nights, when the desk opens. Not the confirmation. */ +const PUBLIC_ACCOMMODATION_COLUMNS = [ + 'id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out', 'notes', +].map(c => `a.${c}`).join(', '); + +/** + * The parts of a booking's metadata that describe the journey rather than the + * ticket. Legs keep their route, carrier and times; the record locator on a + * leg, the seat, the price and anything this list does not name stay behind. + * An allow-list rather than a block-list, because the import writes whatever a + * provider's confirmation carried, and a new field must not become public by + * appearing. + */ +const PUBLIC_METADATA_KEYS = new Set([ + 'airline', 'flight_number', 'departure_airport', 'arrival_airport', + 'train_number', 'platform', 'operator', 'from', 'to', + 'check_in_time', 'check_in_end_time', 'check_out_time', 'hotel', + 'pickup_location', 'dropoff_location', 'vehicle', +]); +const PUBLIC_LEG_KEYS = new Set([ + 'from', 'to', 'airline', 'flight_number', 'train_number', 'platform', 'operator', + 'dep_day_id', 'dep_time', 'arr_day_id', 'arr_time', +]); + +function pickKeys(source: Record, keys: Set): Record { + const out: Record = {}; + for (const key of keys) { + if (source[key] !== undefined) out[key] = source[key]; + } + return out; +} + +/** The public face of a booking's metadata, as the JSON string the row stores. */ +export function publicReservationMetadata(raw: unknown): string | null { + if (raw == null) return null; + let parsed: unknown = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const source = parsed as Record; + const out = pickKeys(source, PUBLIC_METADATA_KEYS); + if (Array.isArray(source.legs)) { + out.legs = source.legs + .filter((leg): leg is Record => !!leg && typeof leg === 'object' && !Array.isArray(leg)) + .map(leg => pickKeys(leg, PUBLIC_LEG_KEYS)); + } + return JSON.stringify(out); +} + +/** + * A link the page may render as one: http or https, nothing else. + * + * The owner types these, and a `javascript:` or `data:` value would otherwise + * become an anchor on a page anyone with the link can open. + */ +export function publicHttpUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null; + } catch { + return null; + } +} + @Injectable() export class ShareService { constructor( @@ -140,6 +236,28 @@ export class ShareService { * is never even queried. share_map covers the whole itinerary: days, their * assignments/notes, and the place list with coordinates, addresses and notes. */ + /** + * The ordered stops of every public booking on the trip, by booking id. + * + * One query for the lot rather than one per booking: the map draws the + * route from these, and a trip with forty bookings is not forty round trips + * to the database. + */ + private publicEndpointsByReservation(tripId: number): Map>> { + const rows = this.dbs.all<{ reservation_id: number } & Record>( + `SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time + FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + WHERE r.trip_id = ? ORDER BY e.reservation_id ASC, e.sequence ASC`, + tripId, + ); + const out = new Map>>(); + for (const { reservation_id, ...endpoint } of rows) { + if (!out.has(reservation_id)) out.set(reservation_id, []); + out.get(reservation_id)!.push(endpoint); + } + return out; + } + getSharedTripData(token: string): Record | null { const shareRow = this.dbs.get( "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", @@ -181,6 +299,7 @@ export class ShareService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -201,6 +320,8 @@ export class ShareService { id: a.place_id, name: a.place_name, description: a.place_description, lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, price: a.price, place_time: a.place_time, end_time: a.end_time, + duration_minutes: a.duration_minutes, notes: a.place_notes, + website: publicHttpUrl(a.website), phone: a.phone, image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode, category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, tags: tagsByPlace[a.place_id] ?? [], @@ -218,11 +339,18 @@ export class ShareService { dayNotes = notesByDay; } + // Named columns, not p.*: the pool used to travel whole, which put the + // owner's booking notes on the place, the Google ids and the import + // bookkeeping in front of anybody holding the link (#2320). places = this.dbs.all(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + SELECT ${PUBLIC_PLACE_COLUMNS}, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.trip_id = ? ORDER BY p.created_at DESC - `, tripId).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) })); + `, tripId).map((p) => ({ + ...p, + image_url: rewritePlacePhotoUrl(p.image_url, token), + website: publicHttpUrl(p.website), + })); } // Bookings — reservations carry per-day positions so the client can render @@ -244,14 +372,27 @@ export class ShareService { } // The alias is not cosmetic: the visibility predicate qualifies its column, // and this query had no alias to qualify against. + // Named columns here too. r.* carried the confirmation number, the + // import bookkeeping and the raw metadata — and the metadata is where + // an imported ticket keeps its seat and its record locator. A public + // link shows what the booking is, not what it would take to change it + // (#2320). The endpoints ride along, since the map draws from them. + const endpoints = this.publicEndpointsByReservation(tripId); reservations = this.dbs.all( - `SELECT r.* FROM reservations r + `SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r WHERE r.trip_id = ? AND ${publicReservationSql('r')} ORDER BY r.reservation_time ASC`, tripId) - .map((r) => ({ ...r, day_positions: posMap.get(r.id) ?? null })); + .map((r) => ({ + ...r, + url: publicHttpUrl(r.url), + metadata: publicReservationMetadata(r.metadata), + endpoints: endpoints.get(r.id) ?? [], + day_positions: posMap.get(r.id) ?? null, + })); accommodations = this.dbs.all(` - SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng + SELECT ${PUBLIC_ACCOMMODATION_COLUMNS}, + p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng FROM day_accommodations a JOIN places p ON a.place_id = p.id WHERE a.trip_id = ? AND ${publicStaySql('a')} `, tripId); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 1aeedf23a..604eaf0cb 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -56,7 +56,7 @@ import { } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { ShareService } from '../../../src/nest/share/share.service'; +import { ShareService, publicReservationMetadata } from '../../../src/nest/share/share.service'; import { SettingsService } from '../../../src/nest/settings/settings.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import type { User } from '../../../src/types'; @@ -428,6 +428,140 @@ describe('getSharedTripData', () => { }); }); +// ── #2320: what the public payload carries, and what it withholds ─────────── + +describe('getSharedTripData redaction (#2320)', () => { + it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', () => { + const { trip, token } = seedSharedTrip(); + testDb.prepare(`INSERT INTO reservations + (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) + VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) + .run(trip.id); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(Object.keys(row).sort()).toEqual([ + 'accommodation_id', 'created_at', 'day_id', 'day_positions', 'end_day_id', 'endpoints', 'id', 'location', + 'metadata', 'notes', 'place_id', 'reservation_end_time', 'reservation_time', 'status', 'title', 'trip_id', 'type', 'url', + ]); + expect(row.notes).toBe('Bring the tickets'); + expect(row.url).toBe('https://bahn.example/booking/1'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('PNR9XY'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('ext-42'); + }); + + it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', () => { + const { trip, token } = seedSharedTrip(); + const metadata = JSON.stringify({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05', confirmation_number: 'LEG1', seat: '3C' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10', confirmation_number: 'LEG2' }, + ], + }); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata) + VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata); + + const data = svc.getSharedTripData(token)!; + const [row] = data.reservations as any[]; + const meta = JSON.parse(row.metadata); + + expect(meta).toEqual({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10' }, + ], + }); + const whole = JSON.stringify(data); + for (const secret of ['14A', '812', 'Ada Lovelace', 'LOC123', 'LEG1', 'LEG2', '3C']) { + expect(whole, secret).not.toContain(secret); + } + }); + + it('SHARE-SVC-034: a booking carries its ordered stops', () => { + const { trip, token } = seedSharedTrip(); + const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) + VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; + const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) + VALUES (?, ?, ?, ?, ?, ?, ?)`); + ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84); + ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(row.endpoints.map((e: any) => [e.role, e.name])).toEqual([['from', 'Paris'], ['to', 'Lyon']]); + expect(Object.keys(row.endpoints[0]).sort()).toEqual( + ['code', 'lat', 'lng', 'local_date', 'local_time', 'name', 'role', 'sequence', 'timezone'], + ); + }); + + it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url); + } + const place = createPlace(testDb, trip.id, { name: 'Museum' }); + testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id); + createDayAssignment(testDb, day.id, place.id, {}); + + const data = svc.getSharedTripData(token)!; + const urls = Object.fromEntries((data.reservations as any[]).map((r) => [r.title, r.url])); + expect(urls).toEqual({ ok: 'https://example.com/x', js: null, data: null, junk: null, blank: null }); + expect(data.assignments[day.id][0].place.website).toBeNull(); + expect(data.assignments[day.id][0].place.phone).toBe('+43 1 234'); + expect((data.places as any[])[0].website).toBeNull(); + }); + + it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue', + duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id); + createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); + + const data = svc.getSharedTripData(token)!; + const entry = data.assignments[day.id][0]; + expect(entry.notes).toBe('go early'); + expect(entry.place).toEqual(expect.objectContaining({ + description: 'Big museum', address: 'Rue de Rivoli', notes: 'Skip the pyramid queue', + duration_minutes: 180, website: 'https://louvre.fr', phone: '+33 1', + })); + const pool = (data.places as any[])[0]; + expect(pool.notes).toBe('Skip the pyramid queue'); + expect(pool).not.toHaveProperty('reservation_notes'); + expect(pool).not.toHaveProperty('google_place_id'); + expect(JSON.stringify(data)).not.toContain('Booked under Ada'); + expect(JSON.stringify(data)).not.toContain('ChIJ123'); + }); + + it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', () => { + const { trip, token } = seedSharedTrip(); + const place = createPlace(testDb, trip.id, { name: 'Hotel' }); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) + VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); + + const data = svc.getSharedTripData(token)!; + const [stay] = data.accommodations as any[]; + expect(stay).toEqual(expect.objectContaining({ check_in: '15:00', check_out: '11:00', notes: 'Ask for a quiet room', place_name: 'Hotel' })); + expect(stay).not.toHaveProperty('confirmation'); + expect(JSON.stringify(data)).not.toContain('HOTEL-777'); + }); + + it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => { + expect(publicReservationMetadata(null)).toBeNull(); + expect(publicReservationMetadata('not json')).toBeNull(); + expect(publicReservationMetadata('[1,2]')).toBeNull(); + expect(publicReservationMetadata('"str"')).toBeNull(); + expect(publicReservationMetadata({ airline: 'OS', legs: [null, 'x', { from: 'VIE', seat: '1A' }] })) + .toBe(JSON.stringify({ airline: 'OS', legs: [{ from: 'VIE' }] })); + }); +}); + // ── getSharedPlacePhotoKey ─────────────────────────────────────────────────── describe('getSharedPlacePhotoKey', () => {