diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index b65ba7dbd..087965f83 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => { ) }) + it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })), + http.get('/api/plugin-search', () => + HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })), + ) + + // Appended, not interleaved: the core list keeps the order it earned. + await expect(mapsApi.search('Rome')).resolves.toEqual({ + places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }], + source: 'openstreetmap+plugin:demo', + }) + }) + + it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })), + http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ) + + // The optional index being unwell must never cost the search that worked. + await expect(mapsApi.search('Rome')).resolves.toEqual({ + places: [{ name: 'Core hit' }], + source: 'openstreetmap', + }) + }) + it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true }))) diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index be189f2e1..474845ec1 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => { expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F') }) + /** + * The one call in this file that is deliberately TWO requests, which is why it is not + * in the list above: a search asks TREK's own indexes and any installed search-provider + * plugin at the same time, and the caller gets one list back (#2221). + */ + it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => { + log = [] + await mapsApi.search('Rome') + expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([ + 'GET /api/plugin-search', + 'POST /api/maps/search', + ]) + }) + it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => { await assertCalls([ - { n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' }, { n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' }, { n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' }, { n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 07ce7d621..532beeaef 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1143,6 +1143,50 @@ async function withCachedPlaces( } } +/** + * What plugins implementing `searchProvider` found for the same query (#2221). + * + * Its own request beside the core one rather than a branch inside it: the core search + * is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This + * answers with an empty list for every failure there is — no provider installed, one + * that timed out, a 404 on an older server, the network gone — because a search that + * breaks when an optional index is unwell is worse than one without it. + */ +/** + * How long a plugin index may keep the search list waiting, in milliseconds. + * + * The host gives a provider two seconds to answer and this gives the round trip a + * little more. It is a ceiling on the WAIT, not on the provider: with no search + * plugin installed the route answers immediately without reaching any of them, so + * the normal case costs one local round trip and nothing else. + * + * A deadline rather than patience, because the alternative is a search that feels + * broken. A list that arrives without an optional index is a smaller answer; a list + * that arrives four seconds late is no answer at all. + */ +const PLUGIN_SEARCH_DEADLINE_MS = 2500 + +async function pluginSearchPlaces( + query: string, + lang?: string, + near?: { lat: number; lng: number }, +): Promise[]> { + const stop = new AbortController() + const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS) + try { + const r = await apiClient.get('/plugin-search', { + params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng }, + signal: stop.signal, + }) + const places = (r.data as { places?: unknown })?.places + return Array.isArray(places) ? (places as Record[]) : [] + } catch { + return [] + } finally { + clearTimeout(timer) + } +} + export const mapsApi = { /** * `locationBias` is what tells the search which "Hase-dera" is meant, and it @@ -1152,10 +1196,25 @@ export const mapsApi = { * It also decides whether the index answers at all: a common single word * without coordinates is refused upstream as too expensive, and the search * then falls back to Nominatim alone. + * + * Plugin indexes are asked at the same time and their hits are appended, so every + * caller of this one function gets them without knowing they exist. Appended rather + * than interleaved: the core list is ordered by relevance and has earned that order, + * and a plugin's row carries its own `source` for a caller that wants to mark it. */ search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) => - withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () => - apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))), + withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { + // Side by side, so the wait is the slower of the two rather than their sum. Only + // the core call may reject: that rejection is what hands withCachedPlaces the + // offline path, and a plugin failure must never trigger it. + const [core, extra] = await Promise.all([ + apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), + pluginSearchPlaces(query, lang, locationBias), + ]) + if (extra.length === 0) return core + const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+') + return { places: [...core.places, ...extra], source: `${core.source}+${from}` } + }), autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => withCachedPlaces( input, 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/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 9a9b3762b..46c22033a 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'collab:message:created', 'collab:message:reacted', 'collab:message:deleted', + // Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is + // routed through live in that hook and nowhere else; a store slice for them would be a + // second copy of the same list to keep in step. + 'roadtripVia:changed', + 'roadtripTrack:changed', // In-app notifications — hooks/useInAppNotificationListener 'notification:new', 'notification:updated', diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 15213f89e..a0abde00f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) 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/Journey/JourneyDetailPageDatePicker.test.tsx b/client/src/components/Journey/JourneyDetailPageDatePicker.test.tsx new file mode 100644 index 000000000..37f092878 --- /dev/null +++ b/client/src/components/Journey/JourneyDetailPageDatePicker.test.tsx @@ -0,0 +1,101 @@ +// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006 +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { DatePicker } from './JourneyDetailPageDatePicker' + +function open(value = '2026-09-11') { + const onChange = vi.fn() + render() + return { onChange } +} + +const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i }) +const prev = (name: RegExp) => screen.getByRole('button', { name }) + +describe('DatePicker year navigation (#2318)', () => { + it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + expect(screen.getByText('Mo')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026') + }) + + it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + + await user.click(screen.getByRole('button', { name: 'Select month' })) + expect(screen.queryByText('Mo')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026') + + await user.click(screen.getByRole('button', { name: 'Select year' })) + expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument() + expect(header()).toHaveTextContent('2016 – 2027') + }) + + it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(screen.getByRole('button', { name: '2023' })) + + // Back on the months, of the chosen year. + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023') + await user.click(screen.getByRole('button', { name: 'Mar' })) + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023') + expect(screen.getByText('Mo')).toBeInTheDocument() + }) + + it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => { + const user = userEvent.setup() + open() + await user.click(screen.getByText(/Sep 11, 2026/)) + + await user.click(prev(/previous month/i)) + expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026') + + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(prev(/previous year/i)) + expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025') + + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(prev(/previous years/i)) + expect(header()).toHaveTextContent('2004 – 2015') + await user.click(screen.getByRole('button', { name: /next years/i })) + expect(header()).toHaveTextContent('2016 – 2027') + }) + + it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => { + const user = userEvent.setup() + const { onChange } = open() + await user.click(screen.getByText(/Sep 11, 2026/)) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(screen.getByRole('button', { name: '2023' })) + await user.click(screen.getByRole('button', { name: 'Mar' })) + await user.click(screen.getByRole('button', { name: '15' })) + expect(onChange).toHaveBeenCalledWith('2023-03-15') + expect(screen.queryByText('Mo')).not.toBeInTheDocument() + }) + + it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => { + const user = userEvent.setup() + open() + const trigger = screen.getByText(/Sep 11, 2026/) + await user.click(trigger) + await user.click(screen.getByRole('button', { name: 'Select month' })) + await user.click(screen.getByRole('button', { name: 'Select year' })) + await user.click(trigger) + expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument() + await user.click(trigger) + expect(screen.getByText('Mo')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx index 2eaa993c6..733519d28 100644 --- a/client/src/components/Journey/JourneyDetailPageDatePicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageDatePicker.tsx @@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate' import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react' import { useTranslation } from '../../i18n' +/** + * The header steps up a level on each click, days → months → years, and a + * pick steps back down. Mirrors CustomDateTimePicker, so a person who has + * learnt one calendar in TREK knows the other. Without it the only way to a + * different year was one month at a time, which on a photo picker asked to + * cover a trip three years back meant thirty-six clicks (#2318). + */ +type CalendarView = 'days' | 'months' | 'years' +const YEAR_PAGE_SIZE = 12 + export function DatePicker({ value, onChange, tripDates }: { value: string onChange: (date: string) => void @@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: { }) { const { t } = useTranslation() const [open, setOpen] = useState(false) + const [view, setView] = useState('days') + const [yearPageStart, setYearPageStart] = useState(0) const [viewMonth, setViewMonth] = useState(() => { const d = value ? new Date(value + 'T00:00:00') : new Date() return { year: d.getFullYear(), month: d.getMonth() } @@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: { setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 }) } + const handlePrev = () => { + if (view === 'days') prevMonth() + else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 })) + else setYearPageStart(s => s - YEAR_PAGE_SIZE) + } + const handleNext = () => { + if (view === 'days') nextMonth() + else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 })) + else setYearPageStart(s => s + YEAR_PAGE_SIZE) + } + const handleHeaderClick = () => { + if (view === 'days') { + setView('months') + } else if (view === 'months') { + setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE) + setView('years') + } + } + const selectMonth = (month: number) => { + setViewMonth(p => ({ ...p, month })) + setView('days') + } + const selectYear = (year: number) => { + setViewMonth(p => ({ ...p, year })) + setView('months') + } + const toggleOpen = () => { + // Reopening lands on the days again: a picker left on the year grid + // would otherwise open there next time, one level away from a date. + setView('days') + setOpen(!open) + } + + const prevLabel = view === 'days' ? t('common.datepicker.prevMonth') + : view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears') + const nextLabel = view === 'days' ? t('common.datepicker.nextMonth') + : view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears') + const headerLabel = view === 'days' ? monthName + : view === 'months' ? String(viewMonth.year) + : `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}` + const headerAria = view === 'days' ? t('common.datepicker.selectMonth') + : view === 'months' ? t('common.datepicker.selectYear') : undefined + const pad = (n: number) => String(n).padStart(2, '0') const cells: (number | null)[] = [] for (let i = 0; i < firstDow; i++) cells.push(null) for (let d = 1; d <= daysInMonth; d++) cells.push(d) + const monthNames = Array.from({ length: 12 }, (_, i) => + new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' })) + const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i) + const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null + const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500' + const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${ + selected + ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' + : 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700' + }` + return (
- {monthName} - +
- {/* Weekday headers */} -
- {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => ( -
{d}
- ))} -
- - {/* Day grid */} -
- {cells.map((day, i) => { - if (day === null) return
- const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}` - const isSelected = dateStr === value - const isTrip = tripDates?.has(dateStr) - const isToday = dateStr === localIsoDate() - - return ( - - ) - })} -
+ ))} +
+ )} + + {view === 'years' && ( +
+ {years.map(y => ( + + ))} +
+ )} + + {view === 'days' && ( + <> + {/* Weekday headers */} +
+ {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => ( +
{d}
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => { + if (day === null) return
+ const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}` + const isSelected = dateStr === value + const isTrip = tripDates?.has(dateStr) + const isToday = dateStr === localIsoDate() + + return ( + + ) + })} +
+ + )}
)} diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 0ac36bc0f..72d69f3f5 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), panBy: vi.fn(), - latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })), + // A flat projection, 1000 px per degree: far enough apart that every + // booking line clears its declutter floor, which is measured along the + // projected line (#2275) rather than read off a canned distanceTo. + latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({ + x: lng * 1000, y: lat * 1000, + distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) }, + })), // Panes: jsdom has none, so keep them in a map the pane tests can read back. panes: new Map(), getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }), @@ -221,15 +227,22 @@ describe('MapView', () => { expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) - it('FE-COMP-MAPVIEW-006b: the whole-trip overview colours each line, on a white casing', () => { - render() + it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => { + render() const [casing, core] = screen.getAllByTestId('polyline') .map(el => JSON.parse(el.getAttribute('data-path-options') as string)) - // White under the colour, the same trick the coloured GPX tracks use to stay - // readable on satellite and dark basemaps. - expect(casing.color).toBe('#ffffff') - expect(core.color).toBe('#ea580c') + expect(casing.color).toBe('#c2740a') + expect(core.color).toBe('#ff9f0a') + }) + + it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => { + render() + + const [casing, core] = screen.getAllByTestId('polyline') + .map(el => JSON.parse(el.getAttribute('data-path-options') as string)) + expect(casing.color).toBe('#0a5cc2') + expect(core.color).toBe('#0a84ff') }) it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => { diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0651a582c..5d17af5f6 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -606,8 +606,10 @@ 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 = [], - routeColors, selectedPlaceId = null, hoverDisabled = false, onMarkerClick, @@ -1028,15 +1030,12 @@ export const MapView = memo(function MapView({ {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. The casing carries the click when the route can be reshaped: it is the wider of - the two, so it is the one a pointer actually lands on. - In the whole-trip overview each day is drawn in its own colour (#1736), and the - casing goes white — the same trick the coloured GPX tracks use to stay readable - on satellite and dark basemaps, where a per-colour dark casing would not. */} + the two, so it is the one a pointer actually lands on. */} {route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [ { @@ -1050,7 +1049,7 @@ export const MapView = memo(function MapView({ , ] : [])} diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index e2e07abed..26e38f607 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1540,17 +1540,19 @@ describe('MapViewGL', () => { expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]]) }) - it('FE-COMP-MAPVIEWGL-048b: the whole-trip overview colours each line on the feature', async () => { + it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => { const routeSource = geoSource() glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null)) - render() + render() await act(async () => {}) const { features } = lastData(routeSource) - expect(features[0].properties.color).toBe('#1d4ed8') + expect(features[0].properties.color).toBe('#ff9f0a') + expect(features[0].properties.casing).toBe('#c2740a') // No colour means the day route's own blue, which the layer paint falls back to. - expect(features[1].properties.color).toBeUndefined() + expect(features[1].properties.color).toBeNull() }) it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => { @@ -1968,4 +1970,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 54604ac9e..0e830a51f 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 } @@ -107,9 +123,12 @@ 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[] - /** Line colour per entry of `route`, same index — the whole-trip overview (#1736). */ - routeColors?: (string | null)[] selectedPlaceId?: number | null onMarkerClick?: (id: number) => void hoverDisabled?: boolean @@ -235,6 +254,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. @@ -554,8 +611,8 @@ export function MapViewGL({ routeVias = NO_ROUTE_VIAS, accessLines = NO_ACCESS_LINES, route = null, + routeColors = null, routeSegments = NO_ROUTE_SEGMENTS, - routeColors, selectedPlaceId = null, hoverDisabled = false, onMarkerClick, @@ -597,6 +654,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 @@ -702,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' @@ -790,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. * @@ -1118,7 +1219,9 @@ export function MapViewGL({ id: 'trip-route-casing', type: 'line', source: 'trip-route', - paint: { 'line-color': ['case', ['has', 'color'], '#ffffff', '#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 @@ -1825,13 +1928,16 @@ export function MapViewGL({ if (!map) return const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined if (!src) return - // The colour rides on the feature rather than the layer so one source can hold a - // whole trip of differently-coloured days (#1736), the way trip-gpx already does. - const features = (route || []).flatMap((seg, i) => seg && seg.length > 1 ? [{ - type: 'Feature' as const, - properties: routeColors?.[i] ? { color: routeColors[i] } : {}, - 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, routeColors, mapReady]) @@ -2063,6 +2169,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 (
@@ -2098,6 +2216,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 && ( diff --git a/client/src/components/Map/ReservationOverlay.test.tsx b/client/src/components/Map/ReservationOverlay.test.tsx index a0dd6c068..f19904dea 100644 --- a/client/src/components/Map/ReservationOverlay.test.tsx +++ b/client/src/components/Map/ReservationOverlay.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024 +// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045 import React from 'react' import { describe, it, expect, vi, afterEach } from 'vitest' import { render, screen } from '../../../tests/helpers/render' @@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => { expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]]) }) + it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => { + // Endpoints 50 px apart, under the 80 px car floor, so the straight hop would + // hide. The road between them loops out 0.3° (300 px) and back, which is what + // is actually drawn — and what the person was missing on their map. + const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]] + const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] }) + expect(lines()).toHaveLength(0) + unmount() + + renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) }) + expect(lines()).toHaveLength(1) + expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road) + }) + + it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => { + // From and to sit 20 px apart; the stop is 400 px away, so the two legs + // together draw 800 px of line. + renderOverlay({ + reservations: [booking({ + id: 8, type: 'car', + endpoints: [ + endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }), + endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }), + endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }), + ], + })], + }) + expect(lines()).toHaveLength(2) + }) + + it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => { + // Every point of the road projects within 30 px: nothing worth drawing yet. + const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]] + renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) }) + expect(lines()).toHaveLength(0) + }) + it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => { renderOverlay({ reservations: [booking({ endpoints: undefined })] }) expect(lines()).toHaveLength(0) diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 22e380dd2..8a34cc99d 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry import { geodesicArcs } from './flightGeodesy' import { cleanEndpointName } from './reservationName' import { useSettingsStore } from '../../store/settingsStore' +import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes' import type { Reservation, ReservationEndpoint } from '../../types' const ENDPOINT_PANE = 'reservation-endpoints' @@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal return h > 0 ? `${h}h ${m}m` : `${m}m` } +/** What a hop will draw: the road it was routed along when there is one, else its arcs. */ +function linesFor(item: TransportItem, roadRoutes: Map | undefined): [number, number][][] { + const road = roadRoutes?.get(item.res.id) + return road && road.length >= 2 ? [road] : item.arcs +} + interface TransportItem { res: Reservation from: ReservationEndpoint @@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn }, [reservations]) const visibleItems = useMemo(() => { + const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]]) return items.filter(item => { // A transit journey draws its real rail/bus alignment, not a straight from->to // line, so the endpoint-proximity declutter (which exists to hide tiny no-op // straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one // with no other places to tighten the map onto — hides the whole route (#1570). if (item.transitSegs.length > 0) return true - const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]) - const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]) - const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200 - return fromPx.distanceTo(toPx) >= minPx + // Measured along what is drawn, not between the ends: a routed drive can + // cover half the screen while its endpoints sit close together (#2275). + return hopIsVisible(item.type, linesFor(item, roadRoutes), project) }) - }, [items, zoom, map]) + }, [items, zoom, map, roadRoutes]) const labelVisibleIds = useMemo(() => { const set = new Set() for (const item of visibleItems) { const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]) const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]) - const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400 - if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id) + if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id) } return set }, [visibleItems, zoom, map]) @@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn )) } // Prefer the real road route (car/bus/taxi/bicycle) over the straight arc. - const road = roadRoutes?.get(item.res.id) - const lines = road && road.length >= 2 ? [road] : item.arcs - return lines.map((seg, segIdx) => ( + return linesFor(item, roadRoutes).map((seg, segIdx) => ( 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/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 788b20e5a..10bddc871 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -3,6 +3,9 @@ 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 { 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 // project-osrm.org demo is car-only (it ignores the profile in the URL). Use @@ -50,6 +53,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 +155,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 +362,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() @@ -352,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. @@ -416,7 +489,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() @@ -446,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)) }, + } } /** @@ -514,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 } /** @@ -528,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 } @@ -563,16 +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` - const response = await fetch(url, { signal }) + 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. @@ -614,7 +836,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() @@ -629,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) @@ -659,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/TripRouteOverview.test.tsx b/client/src/components/Map/TripRouteOverview.test.tsx index 1dfbd5fd3..93a91eca7 100644 --- a/client/src/components/Map/TripRouteOverview.test.tsx +++ b/client/src/components/Map/TripRouteOverview.test.tsx @@ -5,8 +5,8 @@ import type { TripRouteOverview } from './useTripRouteOverview' const overview = (over: Partial = {}): TripRouteOverview => ({ days: [ - { dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: '#1d4ed8', lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] }, - { dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: '#ea580c', lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] }, + { dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] }, + { dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] }, ], lines: [], lineColors: [], segments: [], focusPoints: [], totalDistance: 222500, totalDuration: 10800, loading: false, diff --git a/client/src/components/Map/TripRouteOverview.tsx b/client/src/components/Map/TripRouteOverview.tsx index 7392ca1be..5c0145b89 100644 --- a/client/src/components/Map/TripRouteOverview.tsx +++ b/client/src/components/Map/TripRouteOverview.tsx @@ -94,7 +94,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect const label = day.title || t('dayplan.dayN', { n: day.dayNumber }) const row = ( <> - + {label} diff --git a/client/src/components/Map/reservationsMapbox.test.ts b/client/src/components/Map/reservationsMapbox.test.ts index 15ae03367..c150a8230 100644 --- a/client/src/components/Map/reservationsMapbox.test.ts +++ b/client/src/components/Map/reservationsMapbox.test.ts @@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => { attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled) expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI']) }) + + it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => { + // 50 px between the ends, under the 80 px car floor; the road loops out 300 px. + const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]] + const bare = freshMap() + attach(bare).update([hop(7, 'car', 0.05)], opts) + expect(features(bare)).toHaveLength(0) + + const routed = freshMap() + attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]])) + expect(features(routed)).toHaveLength(1) + expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat])) + }) + + it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => { + const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]] + const map = freshMap() + attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]])) + expect(features(map)).toHaveLength(0) + }) }) describe('ReservationMapboxOverlay waypoint edge cases', () => { diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 8b6e64a63..5972e737d 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram import { getTransitMapSegments } from './transitGeometry' import { geodesicArcs } from './flightGeodesy' import { cleanEndpointName } from './reservationName' +import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes' import { escapeHtml } from '@trek/shared' import type { Reservation, ReservationEndpoint } from '../../types' @@ -259,27 +260,31 @@ export class ReservationMapboxOverlay { }) } + /** What a hop will draw: the road it was routed along when there is one, else its arcs. */ + private linesFor(item: TransportItem): [number, number][][] { + const road = this.roadRoutes.get(item.res.id) + return road && road.length >= 2 ? [road] : item.arcs + } + private render() { const map = this.map if (!this.map.getSource(RESERVATION_SOURCE_ID)) return const show = this.opts.showConnections - // Visible filter: require the on-screen pixel distance between - // endpoints to exceed a type-specific minimum, same as the Leaflet - // overlay, so tiny no-op transport lines don't clutter the map. + // Visible filter: a hop draws once what it will draw is long enough on + // screen to be worth it, same as the Leaflet overlay, so tiny no-op + // transport lines don't clutter the map. Measured along the drawn line, + // not between the ends: a routed drive can cover half the screen while + // its endpoints sit close together (#2275). + const project = (p: readonly [number, number]) => map.project([p[1], p[0]]) const visibleItems = show ? this.items.filter(item => { try { // A transit journey draws its real alignment, not a straight from->to line, so // don't let the endpoint-proximity declutter hide it when the map is zoomed out // (#1570). Mirrors the Leaflet overlay. if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true - const fromPx = map.project([item.from.lng, item.from.lat]) - const toPx = map.project([item.to.lng, item.to.lat]) - const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y - const dist = Math.sqrt(dx * dx + dy * dy) - const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200 - return dist >= minPx + return hopIsVisible(item.type, this.linesFor(item), project) } catch { return true } }) : [] @@ -293,8 +298,7 @@ export class ReservationMapboxOverlay { const toPx = map.project([item.to.lng, item.to.lat]) const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y const dist = Math.sqrt(dx * dx + dy * dy) - const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400 - if (dist >= minPx) labelVisibleIds.add(item.res.id) + if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id) } catch { /* ignore */ } } } @@ -320,9 +324,7 @@ export class ReservationMapboxOverlay { })) } // Prefer the real road route (car/bus/taxi/bicycle) over the straight arc. - const road = this.roadRoutes.get(item.res.id) - const lines = road && road.length >= 2 ? [road] : item.arcs - return lines.map(seg => ({ + return this.linesFor(item).map(seg => ({ type: 'Feature' as const, properties: { resId: item.res.id, 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/client/src/components/Map/useMergedMapPois.test.ts b/client/src/components/Map/useMergedMapPois.test.ts new file mode 100644 index 000000000..63d880bb3 --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.test.ts @@ -0,0 +1,86 @@ +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) + }) +}) + +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 new file mode 100644 index 000000000..a82209ba6 --- /dev/null +++ b/client/src/components/Map/useMergedMapPois.ts @@ -0,0 +1,42 @@ +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. + * + * `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[], offered: Poi[] = NONE): Poi[] { + return useMemo(() => { + 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/Map/useTripRouteOverview.test.ts b/client/src/components/Map/useTripRouteOverview.test.ts index 2d33ca919..fd7d2e596 100644 --- a/client/src/components/Map/useTripRouteOverview.test.ts +++ b/client/src/components/Map/useTripRouteOverview.test.ts @@ -1,6 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { TRACK_COLORS } from '@trek/shared' +import { dayColor } from '../Roadtrip/dayColors' import { useTripRouteOverview } from './useTripRouteOverview' import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' import type { AssignmentsMap, RouteSegment } from '../../types' @@ -64,16 +64,18 @@ describe('useTripRouteOverview', () => { expect(result.current.totalDuration).toBe(1800) }) - it('FE-MAP-TRO-003: gives each day its own colour from the map palette', async () => { + it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => { const { result } = render() await waitFor(() => expect(result.current.loading).toBe(false)) const colors = result.current.days.map(d => d.color) - expect(colors[0]).toBe(TRACK_COLORS[1]) - expect(colors[1]).toBe(TRACK_COLORS[2]) + // The same palette either way, so a day does not change colour when the trip is + // read as a road trip instead of an overview. + expect(colors[0]).toEqual(dayColor(1)) + expect(colors[1]).toEqual(dayColor(2)) // Every drawn line is labelled with the colour of the day it belongs to. expect(result.current.lineColors).toHaveLength(result.current.lines.length) - expect(new Set(result.current.lineColors).size).toBe(2) + expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2) }) it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => { diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts index 5aa8f34d3..5487e3bb4 100644 --- a/client/src/components/Map/useTripRouteOverview.ts +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -3,7 +3,7 @@ import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator' import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan' import { resolveLegMode } from '../Planner/legMode' import { useSettingsStore } from '../../store/settingsStore' -import { TRACK_COLORS } from '@trek/shared' +import { dayColor } from '../Roadtrip/dayColors' import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types' /** One travel day of the overview: the roads it covers, in its own colour. */ @@ -12,7 +12,9 @@ export interface TripOverviewDay { dayNumber: number date: string | null title: string | null - color: string + /** The core and casing this day is drawn in — the road trip's palette, so a day is + * the same colour whichever way the trip is being read. */ + color: { line: string; casing: string } /** One polyline per run of the day, `[lat, lng]`. */ lines: [number, number][][] segments: RouteSegment[] @@ -28,7 +30,7 @@ export interface TripRouteOverview { /** Every day's polylines flattened in trip order — what the map draws. */ lines: [number, number][][] /** The colour of each entry of `lines`, same index. */ - lineColors: string[] + lineColors: { line: string; casing: string }[] segments: RouteSegment[] /** Every drawn coordinate, so the map can frame the whole trip at once. */ focusPoints: [number, number][] @@ -116,7 +118,7 @@ export function useTripRouteOverview( const controller = new AbortController() abortRef.current = controller - const colorFor = (day: Day) => TRACK_COLORS[Math.abs(day.day_number ?? 0) % TRACK_COLORS.length] + const colorFor = (day: Day) => dayColor(day.day_number ?? 0) // Chunk every day up front, so the routed answers can be filed by position and // reassembled in order however they come back. 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/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/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/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/AnchoredPopover.tsx b/client/src/components/Roadtrip/AnchoredPopover.tsx new file mode 100644 index 000000000..692cea62b --- /dev/null +++ b/client/src/components/Roadtrip/AnchoredPopover.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +/** + * A small panel hung under something in the rail. + * + * The rail has more than one badge worth opening — what kind of stop this is, how full it + * fills — and every one of them needs the same four things: measure before showing, + * flip above when the window is short, close on an outside click or Escape, and close on + * a scroll rather than chase the anchor. Each is one line to get wrong and none of them + * is about what the panel contains, which is why they live here once instead of beside + * every panel that needs them. + * + * Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a + * search and a keyboard model. This is a surface. + */ +export default function AnchoredPopover({ anchor, label, children, onClose }: { + /** The element the panel hangs under. Null renders nothing, which is how it stays closed. */ + anchor: HTMLElement | null + /** What the dialog is called, for anyone who cannot see where it is hanging. */ + label: string + children: React.ReactNode + onClose: () => void +}): React.ReactElement | null { + 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 anchor. A badge sits hard against one 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( +
+ {children} +
, + document.body, + ) +} 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. */} + + +
+
+ ) +} + function DriveBand({ leg, onAskAlternatives, alternativesOpen }: { leg: RouteSegment | undefined /** Asks for other ways of driving this leg. Absent means the route is not editable. */ @@ -377,20 +748,29 @@ 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, onPickFill }: { 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[] /** Opens the kind picker on the disc. Absent leaves the rail read-only. */ onPickKind?: (anchor: HTMLElement) => void + /** Opens the panel that sets how full THIS stop fills, hung under the badge. */ + onPickFill?: (anchor: HTMLElement) => void selected: boolean onSelect?: () => void /** Opens the dialog for how long this pause takes. Absent means the rail is read-only. */ onEditStay?: () => void }): React.ReactElement { const { t } = useTranslation() + // Read here rather than threaded down: both stop shapes need the same two, and the + // badge is the only thing in the rail that depends on them. + const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent) + const { vehicleKind } = useVehicleRange() const kind = STOP_KIND_BY_KEY[stop.stopType ?? ''] const Icon = kind?.Icon ?? ParkingSquare const label = t(kind?.labelKey ?? 'roadtrip.poi.rest') @@ -457,8 +837,16 @@ function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditSta + {refuelsRange(stop.stopType, vehicleKind) ? ( + + ) : null} {spurWorthLabelling(stop.offRoadMeters) ? : null} {(driveFindings ?? []).map(w => )} + {entry?.arrival ? : null} @@ -559,31 +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. - */ -function OvernightBreak(): React.ReactElement { - const { t } = useTranslation() - return ( -
- - - - - - {t('roadtrip.warn.overnight')} - -
- ) -} - /** * The arrival — the one value in the rail the user may have decided themselves. * @@ -627,7 +990,7 @@ function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { ) } -function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind }: { +function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill }: { stop: RoadtripStop /** Position within the day — the same count the map badges its markers with. */ number: number @@ -642,6 +1005,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s starts?: boolean /** Opens the kind picker on the number. Absent leaves the rail read-only. */ onPickKind?: (anchor: HTMLElement) => void + /** Opens the panel that sets how full THIS stop fills, hung under the badge. */ + onPickFill?: (anchor: HTMLElement) => void onSelect?: () => void /** Moves this stop by one place. Absent means the chain is read-only. */ onMove?: (delta: number) => void @@ -651,8 +1016,10 @@ 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 + // Read here rather than threaded down: both stop shapes need the same two, and the + // badge is the only thing in the rail that depends on them. + const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent) + const { vehicleKind } = useVehicleRange() return (
) : null} diff --git a/client/src/components/Roadtrip/StopFillPicker.test.tsx b/client/src/components/Roadtrip/StopFillPicker.test.tsx new file mode 100644 index 000000000..0dbb26f61 --- /dev/null +++ b/client/src/components/Roadtrip/StopFillPicker.test.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import { useSettingsStore } from '../../store/settingsStore' +import StopFillPicker from './StopFillPicker' + +/** + * FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do. + * + * The figure is two-sided: a stop either has its own or borrows the traveller's, and the + * panel has to open showing which. What is worth pinning is the seam between them — + * that an inherited figure reads as chosen, that picking it again hands the stop back to + * the setting rather than writing the same number, and that the kilometre line under the + * pills is absent rather than invented when no range is set. + */ + +let anchor: HTMLElement + +beforeEach(() => { + anchor = document.createElement('button') + document.body.appendChild(anchor) + useSettingsStore.setState({ + settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never, + }) +}) +afterEach(() => { anchor.remove() }) + +function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) { + const onPick = over.onPick ?? vi.fn() + const onClose = vi.fn() + render( + , + ) + return { onPick, onClose } +} + +const pill = (percent: number) => screen.getByRole('button', { name: String(percent) }) + +describe('StopFillPicker', () => { + it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => { + // Nothing said about this stop, so it fills the way every other one does. Opening + // with nothing pressed would suggest the stop has no answer, when it has one. + open({ current: null }) + expect(pill(80)).toHaveAttribute('aria-pressed', 'true') + expect(pill(100)).toHaveAttribute('aria-pressed', 'false') + }) + + it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => { + open({ current: 60 }) + expect(pill(60)).toHaveAttribute('aria-pressed', 'true') + expect(pill(80)).toHaveAttribute('aria-pressed', 'false') + }) + + it('FE-STOPFILL-004: picking a percentage reports it', () => { + const { onPick } = open({ current: null }) + fireEvent.click(pill(60)) + expect(onPick).toHaveBeenCalledWith(60) + }) + + it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => { + // The same gesture the kind picker uses to un-pick a kind. Writing 60 again would + // leave the stop pinned to a number it only ever had by inheritance. + const { onPick } = open({ current: 60 }) + fireEvent.click(pill(60)) + expect(onPick).toHaveBeenCalledWith(null) + }) + + it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => { + // Pressed because it is what the stop uses, but not its own — so clicking it means + // "make this one mine", not "take it off". + const { onPick } = open({ current: null }) + fireEvent.click(pill(80)) + expect(onPick).toHaveBeenCalledWith(80) + }) + + it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => { + open({ current: 60 }) + // 60 % of a 500 km range. + expect(screen.getByText(/300 km/)).toBeInTheDocument() + }) + + it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => { + useSettingsStore.setState({ + settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never, + }) + open({ current: 60 }) + expect(screen.queryByText(/km/)).not.toBeInTheDocument() + }) + + it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => { + // On a stop with no figure of its own it would be a button that says "leave + // everything as it is". + open({ current: null }) + expect(screen.queryByText('Use my default')).not.toBeInTheDocument() + }) + + it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => { + const { onPick } = open({ current: 60 }) + fireEvent.click(screen.getByText('Use my default')) + expect(onPick).toHaveBeenCalledWith(null) + }) +}) diff --git a/client/src/components/Roadtrip/StopFillPicker.tsx b/client/src/components/Roadtrip/StopFillPicker.tsx new file mode 100644 index 000000000..1fa0d63e3 --- /dev/null +++ b/client/src/components/Roadtrip/StopFillPicker.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { RotateCcw } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance } from '../../utils/units' +import AnchoredPopover from './AnchoredPopover' +import { useVehicleRange } from './useVehicleRange' +import { FS } from './typeScale' +import type { DistanceUnit } from '../../types' + +/** + * How full THIS stop fills up. + * + * A property of the stop rather than of the traveller, which is the whole reason it is + * here and not only in the settings dialog: 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. + * + * Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number + * being chosen is a decision — "top up" or "fill right up" — not a measurement, so a + * control that offers the six answers people actually give is one tap where a slider is a + * drag and a field is a keyboard. + * + * Under them, what the choice is worth in kilometres, because 80 % is an abstraction and + * "about 290 km from here" is the thing the traveller is actually deciding. + */ + +/** The answers people actually give. Below half a tank nobody stops on purpose. */ +const PRESETS = [50, 60, 70, 80, 90, 100] as const + +export default function StopFillPicker({ anchor, current, onPick, onClose }: { + /** The element the popover hangs under: the stop's own fill badge. */ + anchor: HTMLElement | null + /** What this stop says, or null when it follows the traveller's own setting. */ + current: number | null + /** A percentage for this stop, or null to hand it back to the setting. */ + onPick: (percent: number | null) => void + onClose: () => void +}): React.ReactElement | null { + const { t } = useTranslation() + const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric' + const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent) + const { rangeKm } = useVehicleRange() + + // Zero, absent and 100 all mean the same thing in the settings, so they read as full + // here too rather than as three different defaults. + const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100 + const shown = current ?? settingPercent + + return ( + +
+

+ {t('roadtrip.stop.fillTitle')} +

+ +
+ {PRESETS.map(percent => { + // Pressed against what the stop will actually use, inherited or not, so the + // panel opens showing where the stop stands rather than showing nothing. + const on = shown === percent + return ( + + ) + })} +
+ + {/* What the choice buys. Absent rather than guessed when no range is set: a + kilometre figure worked out from nothing would be the most convincing wrong + number on the screen. */} +

+ {rangeKm + ? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) }) + : t('roadtrip.stop.fillNoRange')} +

+ + {/* Only once there is something to undo. Without an override it would be a button + that says "leave everything as it is". */} + {current !== null ? ( + + ) : null} +
+
+ ) +} 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/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/corridor.test.ts b/client/src/components/Roadtrip/corridor.test.ts index 4a2df1a92..c05633492 100644 --- a/client/src/components/Roadtrip/corridor.test.ts +++ b/client/src/components/Roadtrip/corridor.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest' import { + boxAround, corridorTiles, distanceToSegmentKm, haversineKm, + pointAtMeters, projectOntoRoute, + sliceAtMeters, simplifyLine, type LatLng, } from './corridor' @@ -155,3 +158,106 @@ 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) + }) +}) + +describe('sliceAtMeters', () => { + // A degree of latitude is about 111 km, so this line is roughly 111 km long and every + // figure below can be read off it without a calculator. + const line: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 53, lng: 13 }] + + it('cuts a stretch out of the middle and interpolates both ends', () => { + const out = sliceAtMeters(line, 30_000, 60_000) + expect(out).toHaveLength(2) + expect(haversineKm(out[0], out[1]) * 1000).toBeGreaterThan(29_000) + expect(haversineKm(out[0], out[1]) * 1000).toBeLessThan(31_000) + }) + + it('two adjacent slices meet exactly, so the parts rebuild the whole', () => { + // The reason this exists: a day's line is its legs end to end, and a night drive + // takes its own leg to the next card. A gap at the join would draw as a break in + // the road. + const first = sliceAtMeters(line, 0, 40_000) + const second = sliceAtMeters(line, 40_000, 80_000) + const end = first[first.length - 1] + expect(haversineKm(end, second[0])).toBeLessThan(0.001) + }) + + it('clamps to the line rather than running past its end', () => { + const out = sliceAtMeters(line, 0, 999_000_000) + expect(out[out.length - 1]).toEqual(line[line.length - 1]) + }) + + it('is empty for a zero-length or backwards range', () => { + expect(sliceAtMeters(line, 50_000, 50_000)).toEqual([]) + expect(sliceAtMeters(line, 60_000, 30_000)).toEqual([]) + }) + + it('is empty for a line that is not one', () => { + expect(sliceAtMeters([], 0, 100)).toEqual([]) + expect(sliceAtMeters([{ lat: 52, lng: 13 }], 0, 100)).toEqual([]) + }) + + it('survives a repeated vertex without producing NaN', () => { + // A concatenated route line really does contain these, and dividing by a zero-length + // segment is how a route full of NaN reaches the map. + const doubled: LatLng[] = [{ lat: 52, lng: 13 }, { lat: 52, lng: 13 }, { lat: 53, lng: 13 }] + const out = sliceAtMeters(doubled, 10_000, 20_000) + expect(out.every(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true) + }) +}) diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts index a00a32760..08886da6b 100644 --- a/client/src/components/Roadtrip/corridor.ts +++ b/client/src/components/Roadtrip/corridor.ts @@ -232,3 +232,111 @@ 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] +} + +/** + * How long a line is, in metres, measured the way `sliceAtMeters` walks it. + * + * Not the same figure the router reports for the same road: that comes off its own graph + * while this is great-circle hops between the vertices it sent back. Anything cutting a + * line at a distance has to measure it with this, or the last cut falls short of the end + * by the difference. + */ +export function lineMetres(line: LatLng[]): number { + let total = 0 + for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1], line[i]) * 1000 + return total +} + +/** + * The stretch of a line between two distances along it, ends included. + * + * The router answers a whole run with one polyline and a leg list beside it, so the road + * belonging to any single leg has to be cut out afterwards. Which matters because a road + * trip draws its stops by the day they are REACHED: a drive across midnight hands its + * stops to the next card, and the road under them has to go with them — otherwise the + * day they set off from keeps a line nobody drives on it, and the corridor search along + * "day 2" looks at road that was covered yesterday. + * + * Both ends are interpolated inside the segment they fall in rather than snapped to the + * nearest vertex, so two adjacent slices meet exactly and concatenating them gives the + * original line back. + */ +export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] { + if (line.length < 2 || toMetres <= fromMetres) return [] + const from = Math.max(0, fromMetres) + const out: LatLng[] = [] + let covered = 0 + for (let i = 1; i < line.length; i++) { + const step = haversineKm(line[i - 1], line[i]) * 1000 + const segStart = covered + const segEnd = covered + step + covered = segEnd + if (segEnd < from) continue + if (segStart > toMetres) break + // A zero-length segment divides by zero; both its ends are the same point, so the + // fraction does not matter and zero is the one that never produces NaN. + const at = (m: number): LatLng => { + const t = step > 0 ? (m - segStart) / 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, + } + } + if (!out.length) out.push(segStart >= from ? line[i - 1] : at(from)) + out.push(segEnd <= toMetres ? line[i] : at(toMetres)) + } + return out.length > 1 ? out : [] +} + +/** + * 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/dayColors.test.ts b/client/src/components/Roadtrip/dayColors.test.ts new file mode 100644 index 000000000..f49875b48 --- /dev/null +++ b/client/src/components/Roadtrip/dayColors.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { dayColor } from './dayColors' + +describe('dayColor', () => { + it('FE-DAYCOLORS-001: day one is the blue the route has always been', () => { + // So a one-day trip looks exactly as it did before colouring by day existed. + expect(dayColor(1).line).toBe('#0a84ff') + }) + + it('FE-DAYCOLORS-002: consecutive days differ', () => { + const seen = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(n => dayColor(n).line)) + expect(seen.size).toBe(8) + }) + + it('FE-DAYCOLORS-003: the ninth day starts the palette again', () => { + expect(dayColor(9)).toEqual(dayColor(1)) + expect(dayColor(17)).toEqual(dayColor(1)) + }) + + it('FE-DAYCOLORS-004: the casing is its own colour, not the core', () => { + // A single fixed casing went muddy under the warm colours, which is why each entry + // carries a darkened hue of its own. + for (const n of [1, 2, 3, 4, 5, 6, 7, 8]) { + expect(dayColor(n).casing).not.toBe(dayColor(n).line) + } + }) + + it('FE-DAYCOLORS-005: a missing or nonsensical day still draws something', () => { + // A day number is read off data, and a route with no colour at all draws nothing. + expect(dayColor(0)).toEqual(dayColor(1)) + expect(dayColor(-3)).toEqual(dayColor(1)) + expect(dayColor(Number.NaN)).toEqual(dayColor(1)) + }) +}) diff --git a/client/src/components/Roadtrip/dayColors.ts b/client/src/components/Roadtrip/dayColors.ts new file mode 100644 index 000000000..942031829 --- /dev/null +++ b/client/src/components/Roadtrip/dayColors.ts @@ -0,0 +1,43 @@ +/** + * A colour per day, for a road trip drawn end to end. + * + * The map draws one blue line for a whole trip, which is right while the days are read as + * separate cards. Once the days are joined into one continuous drive there is nothing in + * the line to say where one ends, so the colour has to do it. + * + * Fixed hex rather than theme tokens, and deliberately so: these sit on map tiles, not on + * the app's own surfaces, and a token that follows the user's accent would put the route + * in the same colour as the terrain on some schemes. The same exception the rest of the + * map's paint takes. + */ + +/** + * Eight, then it repeats. + * + * Picked for separation at a 5px stroke over a pale basemap rather than for a gradient: a + * ramp reads as "later" and days are not a quantity. Blue stays first because it is the + * colour the route has always been, so a one-day trip looks unchanged. + * + * The second value is the casing under the core — the same hue darkened, which is what + * keeps the line readable over water and forest. A single fixed casing would have gone + * muddy under the warm colours. + */ +const DAY_COLORS: readonly [string, string][] = [ + ['#0a84ff', '#0a5cc2'], // theme-lint-disable — map paint, see file comment + ['#ff9f0a', '#c2740a'], // theme-lint-disable + ['#30d158', '#1f9c40'], // theme-lint-disable + ['#bf5af2', '#8e3fb8'], // theme-lint-disable + ['#ff375f', '#c22546'], // theme-lint-disable + ['#64d2ff', '#3d9dc2'], // theme-lint-disable + ['#ffd60a', '#c2a30a'], // theme-lint-disable + ['#ac8e68', '#7d654a'], // theme-lint-disable +] + +/** The core and casing a given day of the trip is drawn in. Day numbers start at 1. */ +export function dayColor(dayNumber: number): { line: string; casing: string } { + // A day number that is missing or nonsensical still has to draw something, and the + // first colour is the one the route had before any of this existed. + const at = Number.isFinite(dayNumber) && dayNumber > 0 ? (dayNumber - 1) % DAY_COLORS.length : 0 + const [line, casing] = DAY_COLORS[at] + return { line, casing } +} diff --git a/client/src/components/Roadtrip/nightSpill.test.ts b/client/src/components/Roadtrip/nightSpill.test.ts new file mode 100644 index 000000000..4773bcfa3 --- /dev/null +++ b/client/src/components/Roadtrip/nightSpill.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from 'vitest' +import { spillChains } from './nightSpill' +import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' +import type { RouteSegment } from '../../types' + +type PlanDay = Pick + +function stop(over: Partial & { assignmentId: number; name: string }): RoadtripStop { + return { + ownerDayId: 1, + ownerIndex: 0, + placeId: over.assignmentId * 10, + lat: 53.5, + lng: 9.9, + time: null, + dwellMinutes: null, + legMode: null, + incomingLegMode: null, + stopType: null, + ...over, + } +} + +/** A day of the plan, with every stop's stored position filled in for it. */ +function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay { + return { + dayId, + dayNumber, + date: null, + title: null, + stops: stops.map((s, i) => ({ ...s, ownerDayId: dayId, ownerIndex: i })), + } +} + +const seg = (minutes: number, km = 100): RouteSegment => + ({ distance: km * 1000, duration: minutes * 60, distanceText: `${km} km`, durationText: `${minutes} min`, mode: 'driving' }) as RouteSegment + +/** Every leg the same length, which is all most of these cases need. */ +const everyLeg = (minutes: number, km = 100) => () => ({ seg: seg(minutes, km), line: [[0, 0], [1, 1]] as [number, number][] }) + +/** No leg routed at all, which is what an unrouted or offline trip looks like. */ +const noLegs = () => undefined + +describe('spillChains', () => { + it('FE-NIGHTSPILL-001: leaves an ordinary day exactly where it is', () => { + const plan = [day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 60 }), + stop({ assignmentId: 2, name: 'Berlin' }), + ])] + + const chains = spillChains(plan, [], everyLeg(180)) + + expect(chains).toHaveLength(1) + expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Berlin']) + expect(chains[0].spills).toEqual([]) + }) + + it('FE-NIGHTSPILL-002: hands the stops reached after midnight to the next day', () => { + // 21:00 + 1 h 30 stay = 22:30, then a three hour drive arrives at 01:30 tomorrow. + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + day(2, 2, [ + stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), + stop({ assignmentId: 4, name: 'Leipzig' }), + ]), + ] + + const chains = spillChains(plan, [], everyLeg(180)) + + expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg']) + expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Wittenberg', 'Leipzig']) + }) + + it('FE-NIGHTSPILL-003: the spill carries the night drive and the time it set off', () => { + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + day(2, 2, [ + stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), + stop({ assignmentId: 4, name: 'Leipzig' }), + ]), + ] + + const chains = spillChains(plan, [], everyLeg(180, 240)) + + expect(chains[1].spills).toHaveLength(1) + const spill = chains[1].spills[0] + expect(spill).toMatchObject({ at: 0, count: 1, fromDayNumber: 1, departure: '22:30' }) + // The leg is the drive that crossed midnight, not the one after it: without it the + // card would gain a stop and none of the distance that reaches it. + expect(spill.leg?.distance).toBe(240000) + expect(spill.line.length).toBeGreaterThan(1) + }) + + it('FE-NIGHTSPILL-004: a stop that moved keeps the day the server stores it on', () => { + const plan = [ + day(7, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), + ] + + const chains = spillChains(plan, [], everyLeg(180)) + + // Drawn under day 2, stored on day 1 — which is what every callback still names. + const moved = chains[1].stops.find(s => s.name === 'Neuruppin') + expect(moved?.ownerDayId).toBe(7) + expect(moved?.ownerIndex).toBe(1) + }) + + it('FE-NIGHTSPILL-005: the last day of a trip keeps its own crossing, having nowhere to send it', () => { + const plan = [day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ])] + + const chains = spillChains(plan, [], everyLeg(180)) + + expect(chains).toHaveLength(1) + expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin']) + expect(chains[0].spills).toEqual([]) + // And it still says so: the arrival is a day past the one the card is under. + expect(chains[0].schedule.entries[1].dayOffset).toBe(1) + }) + + it('FE-NIGHTSPILL-006: a pinned time resolved to yesterday does not tear the day apart', () => { + // The case that broke it: 07:00 then 23:00. `computeSchedule` resolves the second pin + // to the occurrence nearest the drive reaching it, so nine hours late reads as nearer + // than fifteen hours of waiting and 23:00 lands on the PREVIOUS day. The whole chain + // is then lifted to stay positive, which leaves the FIRST stop wearing a day offset + // and the one after it none. Placing stops on those offsets moved the early stop to + // tomorrow and left the late one behind, visiting them out of order. + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Cobbelsdorf', time: '07:00', dwellMinutes: 45 }), + stop({ assignmentId: 2, name: 'Wittenberg', time: '23:00', dwellMinutes: 180 }), + stop({ assignmentId: 3, name: 'Leipzig' }), + ]), + day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]), + ] + + const chains = spillChains(plan, [], everyLeg(50)) + + // Cobbelsdorf and Wittenberg both stay: nothing between them crosses midnight. + expect(chains[0].stops.map(s => s.name)).toEqual(['Cobbelsdorf', 'Wittenberg']) + // Leipzig is reached at 02:50, after the clock went back past 23:00, so it moves. + expect(chains[1].stops.map(s => s.name)).toEqual(['Leipzig', 'Dresden']) + }) + + it('FE-NIGHTSPILL-007: two nights land on two different days', () => { + // 20:00, then two twelve-hour drives: 08:00 the next day, 20:00 that evening, and + // 08:00 the day after — two backwards jumps of the clock, two days on. + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'A', time: '20:00' }), + stop({ assignmentId: 2, name: 'B' }), + stop({ assignmentId: 3, name: 'C' }), + stop({ assignmentId: 4, name: 'D' }), + ]), + day(2, 2, []), + day(3, 3, []), + ] + + const chains = spillChains(plan, [], everyLeg(12 * 60)) + + expect(chains[0].stops.map(s => s.name)).toEqual(['A']) + expect(chains[1].stops.map(s => s.name)).toEqual(['B', 'C']) + expect(chains[2].stops.map(s => s.name)).toEqual(['D']) + }) + + it('FE-NIGHTSPILL-008: a day whose legs never routed moves nothing', () => { + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00' }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), + ] + + const chains = spillChains(plan, [], noLegs) + + // No drive, no arrival, no crossing — and a guess would be worse than nothing. + expect(chains[0].stops.map(s => s.name)).toEqual(['Hamburg', 'Neuruppin']) + expect(chains[1].spills).toEqual([]) + }) + + it('FE-NIGHTSPILL-009: a day that carries no drive of its own still receives one', () => { + const plan = [day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ])] + const quiet: QuietDay[] = [{ dayId: 2, dayNumber: 2, date: null, title: null, stops: [] }] + + const chains = spillChains(plan, quiet, everyLeg(180)) + + expect(chains).toHaveLength(2) + expect(chains[1].dayId).toBe(2) + expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin']) + }) + + it('FE-NIGHTSPILL-010: the crossing is not marked twice on a stop that moved', () => { + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + ]), + day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), + ] + + const chains = spillChains(plan, [], everyLeg(180)) + + // The block around it is what says the night happened; a band as well would say it + // twice. And it is not "the next day" once it is drawn under the next day's date. + expect(chains[1].schedule.warnings.some(w => w.code === 'overnight')).toBe(false) + expect(chains[1].schedule.entries[0].dayOffset).toBe(0) + }) + + it('FE-NIGHTSPILL-011: a finding about a stop travels with it, renumbered', () => { + // Wittenberg is pinned two hours before the drive can get there, which is a "late" + // finding against index 1 of day 1. It moves to day 2 as index 0. + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin', time: '00:30' }), + ]), + day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), + ] + + const chains = spillChains(plan, [], everyLeg(180)) + + const late = chains[1].schedule.warnings.filter(w => w.code === 'late') + expect(late).toHaveLength(1) + expect(late[0].index).toBe(0) + }) + + it('FE-NIGHTSPILL-012: two stops crossing together are one block, not two', () => { + const plan = [ + day(1, 1, [ + stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }), + stop({ assignmentId: 2, name: 'Neuruppin' }), + stop({ assignmentId: 3, name: 'Potsdam' }), + ]), + day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]), + ] + + const chains = spillChains(plan, [], everyLeg(180)) + + expect(chains[1].stops.map(s => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg']) + expect(chains[1].spills).toHaveLength(1) + expect(chains[1].spills[0]).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 }) + }) +}) diff --git a/client/src/components/Roadtrip/nightSpill.ts b/client/src/components/Roadtrip/nightSpill.ts new file mode 100644 index 000000000..70f935eed --- /dev/null +++ b/client/src/components/Roadtrip/nightSpill.ts @@ -0,0 +1,212 @@ +import { computeSchedule, parseClock, type Schedule } from './roadtripModel' +import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' +import type { RouteSegment } from '../../types' + +/** + * Which date each stop of a road trip is drawn on. + * + * A day plan stores a stop against a day, and that is the right model for planning: the + * traveller put it there. But a road trip is read as a drive, and a drive that leaves at + * 21:00 and arrives at 01:23 arrives TOMORROW. The rail used to say so with a band + * between the two stops and then carry on listing the arrival under yesterday's date — + * the one thing the band had just said was wrong. + * + * So a road trip lays its stops out by the day they are actually reached, and the drive + * across midnight travels with them, which is what makes the kilometres count on the day + * they are driven rather than on the day they were set off on. + * + * **Nothing is written.** The stop still belongs to the day it was planned on, the day + * plan shows it there, and every stop carries `ownerDayId`/`ownerIndex` so a reorder, a + * move, a stay edit or a refuel offer still names the day the server knows it by. Because + * this is worked out again from the arrival times every time they change, shortening the + * stay before a night drive brings the stop back on its own — there is no state to undo, + * and no way for the arrangement and the times to disagree. + */ + +/** A stretch of one card's chain that was driven onto it from an earlier day. */ +export interface SpillMark { + /** Where it starts in the card's `stops`. */ + at: number + /** How many stops it covers. */ + count: number + /** The day they are stored on, as the traveller numbers it. */ + fromDayNumber: number + /** When the drive set off, read off that day's own schedule. */ + departure: string | null + /** The drive across midnight itself, or undefined when that leg never routed. */ + leg: RouteSegment | undefined + /** + * The stop the night drive LEAVES from, still on the earlier card. + * + * Needed because that drive is drawn on this card while belonging, leg-wise, to the + * stop before it: anything measured along this card's line that lands on the incoming + * stretch has to be anchored to this stop, not to the first one drawn here. Putting a + * via there and anchoring it to the wrong stop is what made a route double back. + */ + fromStop: RoadtripStop | undefined + /** + * The road it covers, so the card it lands on can draw it and count it. + * + * Without this the night is a hole: the day it set off from ends before it and the day + * it arrives on starts after it, so its kilometres belong to neither and the map draws + * two runs with a gap between them. + */ + line: [number, number][] +} + +/** One date's worth of driving, before the legs and warnings are hung off it. */ +export interface SpillChain { + dayId: number + dayNumber: number + date: string | null + title: string | null + /** In driving order: anything inherited first, then the day's own. */ + stops: RoadtripStop[] + /** Arrivals as each stop's OWN day worked them out, renumbered onto this chain. */ + schedule: Schedule + /** The inherited stretches, in the order they appear. Empty on an ordinary day. */ + spills: SpillMark[] +} + +/** A day as the plan holds it, before anything is routed onto it. */ +type PlanDay = Pick + +/** What the caller can find out about the road between two stops. */ +type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined + +/** One stop on its way to a card, with the times its own day gave it. */ +interface Placed { + stop: RoadtripStop + arrival: string | null + departure: string | null + /** Days past its own day's first stop. Renumbered against the card it lands on. */ + dayOffset: number + /** Findings its own day filed against it, to be renumbered with it. */ + codes: ('late' | 'overnight' | 'leg' | 'range')[] + lateMinutes: number | null + /** Which day it came from, or null when it is the card's own. */ + fromDayNumber: number | null + departedAt: string | null + leg: RouteSegment | undefined + line: [number, number][] + from: RoadtripStop | undefined +} + +/** + * Lays a trip out by the date each stop is reached. + * + * Every day of the trip gets a chain, in trip order, including the ones that carry no + * drive of their own — a day whose only content was driven onto it through the night is + * still a day, and the alternative is a date that silently disappears. The caller drops + * the chains that end up with fewer than two stops, exactly as it did before. + */ +export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegLookup): SpillChain[] { + const all: PlanDay[] = [...plan, ...quietDays.map(d => ({ + dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops, + }))].sort((a, b) => a.dayNumber - b.dayNumber) + const numbers = new Set(all.map(d => d.dayNumber)) + const landing = new Map() + for (const d of all) landing.set(d.dayNumber, []) + + for (const d of all) { + // A day's own schedule, walked over its own stops: this is what says where midnight + // falls, and it is the only reading of these times that is anchored to the times the + // traveller actually pinned. + const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1])) + const legs = routed.map(l => l?.seg) + const schedule = computeSchedule( + d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), + legs.map(l => l?.duration), + ) + /** + * Which day of this chain each stop is reached on, read off the clock itself. + * + * Not `dayOffset`. That is arithmetic on top of a pinned time resolved to the + * occurrence NEAREST the drive reaching it — so a stop pinned at 23:00 after one + * pinned at 07:00 resolves to 23:00 yesterday (nine hours late reads as nearer than + * fifteen hours of waiting), the chain drops below midnight, and the schedule lifts + * the whole day back up to keep it positive. The result is an early stop wearing a + * higher offset than the one after it, and placing stops on those offsets tears the + * drive into pieces that are visited out of order. + * + * A clock that goes backwards is the one signal none of that can fake. Arrivals only + * ever move forward along a drive, so 23:00 followed by 03:12 is one thing and one + * thing only: midnight happened in between. A stop with no arrival yet inherits the + * day of the one before it rather than resetting the count. + */ + let day = 0 + let previous: number | null = null + d.stops.forEach((stop, i) => { + const entry = schedule.entries[i] + const clock = parseClock(entry?.arrival) + if (clock !== null) { + if (previous !== null && clock < previous) day += 1 + previous = clock + } + const offset = day + const marks = schedule.warnings.filter(w => w.index === i) + // A day the trip does not have is not a day to move onto. Those stops stay where + // they are and keep the crossing marked the way the rail always marked it, because + // the only other answer would be to invent a date nobody planned. The ratchet stays + // where it was too, so the rest of the chain stays with them. + const reachable = offset > 0 && numbers.has(d.dayNumber + offset) + const target = reachable ? d.dayNumber + offset : d.dayNumber + const moved = target !== d.dayNumber + landing.get(target)?.push({ + stop, + arrival: entry?.arrival ?? null, + departure: entry?.departure ?? null, + // Renumbered against the card it lands on: 01:23 is not "the next day" once it is + // drawn under the next day's date. A stop that could not move keeps its offset, + // which is what still makes the "+1" and the crossing band read correctly. + dayOffset: moved ? 0 : offset, + // The crossing itself is the spill block's own head wherever the stop moved, so + // it would be said twice. + codes: marks.map(w => w.code).filter(c => !(moved && c === 'overnight')), + lateMinutes: marks.find(w => w.code === 'late')?.minutes ?? null, + fromDayNumber: moved ? d.dayNumber : null, + departedAt: moved ? schedule.entries[i - 1]?.departure ?? null : null, + leg: moved ? legs[i - 1] : undefined, + line: moved ? routed[i - 1]?.line ?? [] : [], + from: moved ? d.stops[i - 1] : undefined, + }) + }) + } + + const out: SpillChain[] = [] + for (const d of all) { + const placed = landing.get(d.dayNumber) ?? [] + if (!placed.length) continue + // Inherited stretches first, oldest day first, then the card's own stops: that is + // the order they are driven in. + placed.sort((a, b) => (a.fromDayNumber ?? Number.MAX_SAFE_INTEGER) - (b.fromDayNumber ?? Number.MAX_SAFE_INTEGER)) + const spills: SpillMark[] = [] + for (let i = 0; i < placed.length; i++) { + const p = placed[i] + if (p.fromDayNumber === null) continue + const last = spills[spills.length - 1] + if (last && last.fromDayNumber === p.fromDayNumber && last.at + last.count === i) last.count += 1 + else spills.push({ at: i, count: 1, fromDayNumber: p.fromDayNumber, departure: p.departedAt, leg: p.leg, line: p.line, fromStop: p.from }) + } + out.push({ + dayId: d.dayId, + dayNumber: d.dayNumber, + date: d.date, + title: d.title, + stops: placed.map(p => p.stop), + schedule: { + entries: placed.map(p => ({ + arrival: p.arrival, + departure: p.departure, + anchored: p.stop.time !== null && p.stop.time !== undefined, + dayOffset: p.dayOffset, + })), + warnings: placed.flatMap((p, i) => p.codes.map(code => ( + code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code } + ))), + }, + spills, + }) + } + return out +} diff --git a/client/src/components/Roadtrip/refuelSuggestion.test.ts b/client/src/components/Roadtrip/refuelSuggestion.test.ts new file mode 100644 index 000000000..886518769 --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.test.ts @@ -0,0 +1,124 @@ +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([], {})).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', () => { + 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..9daf24b80 --- /dev/null +++ b/client/src/components/Roadtrip/refuelSuggestion.ts @@ -0,0 +1,123 @@ +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' + // `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' +} + +/** + * 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/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 0b7ea966a..1dc6b7a30 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', () => { @@ -89,7 +90,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 +101,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. @@ -545,3 +580,234 @@ 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() + }) +}) + +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']) + }) +}) + +describe('deriveDriveWarnings — filling only part way', () => { + const drive = (minutes: number, km: number) => + ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) + + it('FE-ROADTRIP-MODEL-101: a stop that fills to 80 % leaves a fifth already used', () => { + // Nobody charges to 100 % on the road: the last fifth takes as long as the first + // four. Counting a stop as a full tank overstates what comes after it by that fifth. + const out = deriveDriveWarnings( + [drive(60, 100), drive(60, 450)], + [false, true, false], + { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, + 0, + ) + // After the stop the budget restarts at 100 km rather than 0, so the tank is dry + // 400 km into the second leg. Filled all the way it would have gone the whole 450. + expect(out.emptyAt).toHaveLength(1) + expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 }) + }) + + it('FE-ROADTRIP-MODEL-102: filling all the way is what absent, zero and 100 all mean', () => { + const legs = [drive(60, 100), drive(60, 450)] + const refuels = [false, true, false] + const full = { legMinutes: null, dayMinutes: null, rangeKm: 500 } + for (const fillPercent of [undefined, null, 0, 100]) { + const out = deriveDriveWarnings(legs, refuels, { ...full, fillPercent }, 0) + // A full tank after the stop covers the remaining 450 km without a finding. + expect(out.emptyAt).toEqual([]) + expect(out.warnings).toEqual([]) + } + }) + + it('FE-ROADTRIP-MODEL-103: with no range set, the fill level changes nothing', () => { + const out = deriveDriveWarnings( + [drive(60, 900)], + [true, false], + { legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 }, + 0, + ) + 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 c61a8cefd..b9d408dd4 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. * @@ -121,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 ?? '') } /** @@ -131,6 +156,15 @@ export function refuelsRange(stopType: string | null | undefined): boolean { * rather than a sentinel: zero is a legal thing to type and means the same as unset. */ export interface DriveLimits { + /** + * How full a fill-up actually goes, 1 to 100, or null for "all the way". + * + * Nobody charges an electric car to 100 % on the road: the last fifth takes as long as + * the first four and the manual says not to. Treating every stop as a full tank + * overstates the range after it by exactly that fifth, which is a whole leg on a long + * day. + */ + fillPercent?: number | null /** Longest single drive between two stops, in minutes. */ legMinutes: number | null /** Longest total driving in one day, in minutes. */ @@ -212,8 +246,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 +260,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 } @@ -308,6 +373,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. @@ -344,13 +432,45 @@ 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 } { + /** + * 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[] = [] 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 + + /** + * 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 = 0 + 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 @@ -370,7 +490,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 @@ -378,18 +516,19 @@ 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 // morning start with a full tank. - if (refuelsAt[legs.length]) budget = 0 + if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length) const minutes = Math.round(totalSeconds / 60) const day = limits.dayMinutes && minutes > limits.dayMinutes ? { 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 { @@ -639,3 +778,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/useFollowTrack.test.ts b/client/src/components/Roadtrip/useFollowTrack.test.ts index 50989fd8d..a8416ffc9 100644 --- a/client/src/components/Roadtrip/useFollowTrack.test.ts +++ b/client/src/components/Roadtrip/useFollowTrack.test.ts @@ -30,8 +30,8 @@ function day(over: Partial = {}): RoadtripDay { return { dayId: 7, dayNumber: 1, date: null, title: null, stops: [ - { assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, - { assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, ], legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] }, geometry: [[START.lat, START.lng], [END.lat, END.lng]], @@ -41,7 +41,7 @@ function day(over: Partial = {}): RoadtripDay { } const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ - days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false, + days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false, }) function viasStub(over: Partial = {}): RoadtripVias { diff --git a/client/src/components/Roadtrip/useRefuelSearch.test.ts b/client/src/components/Roadtrip/useRefuelSearch.test.ts new file mode 100644 index 000000000..1d6651aec --- /dev/null +++ b/client/src/components/Roadtrip/useRefuelSearch.test.ts @@ -0,0 +1,282 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { haversineKm, pointAtMeters, type Bbox, type LatLng } from './corridor' + +const { pois, offline, stored } = vi.hoisted(() => ({ + pois: vi.fn(), + offline: vi.fn(), + stored: { settings: {} as { roadtrip_vehicle?: string } }, +})) +vi.mock('../../api/client', () => ({ mapsApi: { pois } })) +vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) })) +vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: offline })) +vi.mock('../../store/settingsStore', () => ({ + useSettingsStore: (select: (state: typeof stored) => unknown) => select(stored), +})) + +import { useRefuelSearch } from './useRefuelSearch' + +/** + * A hundred kilometres due north. A straight north-south drive makes "before the dry + * point" a plain statement about latitude, so the direction the search looked in can be + * read off the box the request carried. + */ +const LINE: LatLng[] = [{ lat: 53, lng: 10 }, { lat: 53.9, lng: 10 }] +const DRY_KM = 60 +const DRY = pointAtMeters(LINE, DRY_KM * 1000)! + +const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({ + osm_id: id, + name: id, + lat, + lng, + category, + poi_type: 'amenity=fuel', + address: null, + website: null, + phone: null, + opening_hours: null, + cuisine: null, + source: 'openstreetmap', +}) + +/** + * A station `alongKm` into the day, `eastKm` to the side of the road. + * + * The side step is a rough degree conversion rather than the projection the hook uses: + * nothing here depends on the exact metre, only on a larger `eastKm` really sitting + * further from the line, which is what orders the offers. + */ +const station = (id: string, alongKm: number, eastKm = 0.5) => { + const on = pointAtMeters(LINE, alongKm * 1000)! + return hit(id, on.lat, on.lng + eastKm / 66) +} + +const answer = (items: ReturnType[], over: { truncated?: boolean; clamped?: boolean } = {}) => + ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false, ...over }) + +const boxOf = (call: unknown[]) => call[1] as Bbox +const signalOf = (call: unknown[]) => call[3] as AbortSignal + +/** A request left hanging, so a second one can overtake it. */ +function held() { + let settle!: (value: ReturnType) => void + const promise = new Promise>(resolve => { settle = resolve }) + return { promise, settle } +} + +beforeEach(() => { + pois.mockReset().mockResolvedValue(answer([])) + offline.mockReset().mockReturnValue(false) + stored.settings = {} +}) + +describe('useRefuelSearch', () => { + it('FE-REFUELSEARCH-001: offline it says so rather than offering a stop that cannot be kept', async () => { + // Accepting an offer writes a place and assigns it to a day; only the first of those + // queues offline, so anything accepted here would end up an orphan. Saying "could not + // look" is the honest answer, and it must not cost a request either. + offline.mockReturnValue(true) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(pois).not.toHaveBeenCalled() + expect(result.current.outcome).toBe('failed') + expect(result.current.loading).toBe(false) + // The band still opens on the point that was asked about, or there is nowhere to + // print the answer. + expect(result.current.openFor).toBe('4:1') + }) + + it('FE-REFUELSEARCH-002: somebody who drives a petrol car is not sent to a charger', async () => { + stored.settings = { roadtrip_vehicle: 'combustion' } + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(pois.mock.calls[0][0]).toBe('fuel') + // The names come back in the traveller's language, so the rail is not half German. + expect(pois.mock.calls[0][2]).toBe('en-US') + }) + + it('FE-REFUELSEARCH-003: an electric car is not sent to a petrol pump', async () => { + stored.settings = { roadtrip_vehicle: 'electric' } + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(pois.mock.calls[0][0]).toBe('charging') + }) + + it('FE-REFUELSEARCH-004: with nothing said about the car both kinds are looked for', async () => { + const { result, rerender } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + expect(pois.mock.calls[0][0]).toBe('fuel,charging') + + // And a value this hook has never heard of falls back to both rather than narrowing + // the search to nothing: an empty category list would read on screen as "there is no + // filling station on this road", which is a statement about the road. + stored.settings = { roadtrip_vehicle: 'hovercraft' } + rerender() + await act(async () => { await result.current.ask('4:2', DRY, LINE, DRY_KM, []) }) + expect(pois.mock.calls[1][0]).toBe('fuel,charging') + }) + + it('FE-REFUELSEARCH-005: it looks at the road already driven, not around the dry point', async () => { + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + const box = boxOf(pois.mock.calls[0]) + const centre = { lat: (box.south + box.north) / 2, lng: (box.west + box.east) / 2 } + // South is backwards along this drive. Centring on the dry point itself would spend + // half the circle on road that lies beyond an empty tank. + expect(centre.lat).toBeLessThan(DRY.lat) + expect(haversineKm(centre, DRY)).toBeGreaterThan(11) + expect(haversineKm(centre, DRY)).toBeLessThan(13) + // Pulled back, but not so far that the last kilometres before the tank empties fall + // outside the box: a pump two kilometres short of the dry point is the best answer + // there is. + expect(box.north).toBeGreaterThan(DRY.lat) + }) + + it('FE-REFUELSEARCH-006: a second question abandons the answer to the first', async () => { + const first = held() + pois.mockImplementationOnce(() => first.promise) + const { result } = renderHook(() => useRefuelSearch()) + + let pending!: Promise + act(() => { pending = result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + pois.mockResolvedValueOnce(answer([station('second', 30)])) + await act(async () => { await result.current.ask('5:0', DRY, LINE, DRY_KM, []) }) + + // The abandoned request answers late, the way a slow one does. Landing it now would + // put day four's stations under day five's heading, with day five's accept button + // under them. + await act(async () => { first.settle(answer([station('first', 45)])); await pending }) + + expect(signalOf(pois.mock.calls[0]).aborted).toBe(true) + expect(result.current.results.map(p => p.osm_id)).toEqual(['second']) + expect(result.current.openFor).toBe('5:0') + expect(result.current.loading).toBe(false) + }) + + it('FE-REFUELSEARCH-007: asking again empties the rail before the new answer arrives', async () => { + pois.mockResolvedValueOnce(answer([station('old', 40)])) + const { result } = renderHook(() => useRefuelSearch()) + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + expect(result.current.results).toHaveLength(1) + + pois.mockImplementationOnce(() => held().promise) + act(() => { void result.current.ask('5:0', DRY, LINE, DRY_KM, []) }) + + // Left standing, the first search's stations sit under the new heading for as long as + // the second one takes, and accepting one would file it against the wrong dry point. + expect(result.current.results).toEqual([]) + expect(result.current.offered).toEqual([]) + expect(result.current.outcome).toBeNull() + expect(result.current.loading).toBe(true) + }) + + it('FE-REFUELSEARCH-008: a request that fails is a failure, not an empty road', async () => { + pois.mockRejectedValue(new Error('502')) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(result.current.outcome).toBe('failed') + // Left spinning, the band offers no way to ask again. + expect(result.current.loading).toBe(false) + }) + + it('FE-REFUELSEARCH-009: stations the car cannot reach are not a find', async () => { + // Both come back from the server and both are real filling stations; both sit past + // the point the tank empties, so neither answers the question that was asked. + pois.mockResolvedValue(answer([station('just-past', 58), station('well-past', 70)])) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(result.current.results).toEqual([]) + expect(result.current.outcome).toBe('none') + }) + + it('FE-REFUELSEARCH-010: a search the server cut short is not reported as an empty road', async () => { + pois.mockResolvedValue(answer([], { truncated: true })) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + // "There is nothing here" and "we did not finish looking" send a traveller to two + // different places, and only the server knows which of them happened. + expect(result.current.outcome).toBe('incomplete') + }) + + it('FE-REFUELSEARCH-011: a pump already on the plan is not offered beside itself', async () => { + const planned = station('planned', 40) + pois.mockResolvedValue(answer([planned, station('new', 45, 1.5)])) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { + await result.current.ask('4:1', DRY, LINE, DRY_KM, [{ lat: planned.lat, lng: planned.lng }]) + }) + + expect(result.current.results.map(p => p.osm_id)).toEqual(['new']) + }) + + it('FE-REFUELSEARCH-012: three go on the map, and they are the three the rail lists', async () => { + // Ordered by the detour, so the map has to draw the first three of that same list + // rather than any three: a pin the rail does not name is a stop nobody can accept. + pois.mockResolvedValue(answer([ + station('far', 30, 2.5), + station('nearest', 44, 0.2), + station('third', 36, 1.5), + station('fourth', 32, 2), + station('second', 40, 0.8), + ])) + const { result } = renderHook(() => useRefuelSearch()) + + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + expect(result.current.results).toHaveLength(5) + expect(result.current.offered.map(p => p.osm_id)).toEqual(['nearest', 'second', 'third']) + expect(result.current.offered).toEqual(result.current.results.slice(0, 3)) + }) + + it('FE-REFUELSEARCH-013: with nothing open the offer list is the same array every time', async () => { + // The map rebuilds every pin when this reference changes, and the planner re-renders + // on anything from a drag to an incoming websocket event. A fresh [] per render is a + // map that flickers all day for a search nobody started. + const { result, rerender } = renderHook(() => useRefuelSearch()) + const idle = result.current.offered + + rerender() + expect(result.current.offered).toBe(idle) + + pois.mockResolvedValue(answer([station('a', 40)])) + await act(async () => { await result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + const open = result.current.offered + rerender() + expect(result.current.offered).toBe(open) + + act(() => { result.current.close() }) + expect(result.current.offered).toBe(idle) + }) + + it('FE-REFUELSEARCH-014: closing the band drops the answer and the request behind it', async () => { + pois.mockImplementationOnce(() => held().promise) + const { result } = renderHook(() => useRefuelSearch()) + act(() => { void result.current.ask('4:1', DRY, LINE, DRY_KM, []) }) + + act(() => { result.current.close() }) + + expect(signalOf(pois.mock.calls[0]).aborted).toBe(true) + expect(result.current.openFor).toBeNull() + expect(result.current.results).toEqual([]) + expect(result.current.outcome).toBeNull() + expect(result.current.loading).toBe(false) + }) +}) diff --git a/client/src/components/Roadtrip/useRefuelSearch.ts b/client/src/components/Roadtrip/useRefuelSearch.ts new file mode 100644 index 000000000..1d6ad2413 --- /dev/null +++ b/client/src/components/Roadtrip/useRefuelSearch.ts @@ -0,0 +1,134 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n' +import { isEffectivelyOffline } from '../../sync/networkMode' +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' + +/** + * 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 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. */ + openFor: string | null + 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 +} + +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) + 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 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) + setOutcome(outcomeOf(candidates, answer)) + } catch { + if (!controller.signal.aborted) setOutcome('failed') + } finally { + if (!controller.signal.aborted) setLoading(false) + } + }, [locale, vehicle]) + + 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/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts index 8690f1615..6ddf8dcd4 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts @@ -21,8 +21,8 @@ function day(over: Partial = {}): RoadtripDay { return { dayId: 1, dayNumber: 1, date: null, title: null, stops: [ - { assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, - { assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, + { assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, ], legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] }, geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]], @@ -32,7 +32,7 @@ function day(over: Partial = {}): RoadtripDay { } const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ - days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false, + days, lines: [], lineDays: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false, }) /** The search state the mocked hook hands back, results included. */ diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 59c6bf1ae..5850e09b4 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -1,6 +1,6 @@ import { renderHook, waitFor, act } from '@testing-library/react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { Assignment, AssignmentsMap, Day } from '../../types' +import type { Assignment, AssignmentsMap, Day, Settings } from '../../types' // Hoisted together with the mock: the module factory runs before the file body, so a // class declared down there would not exist yet when the hook does its `instanceof`. @@ -17,6 +17,8 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => { vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs, RoutingRefusedError })) import { useRoadtripRoutes } from './useRoadtripRoutes' +import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore' +import { lineMetres } from './corridor' const HAMBURG: [number, number] = [53.5511, 9.9937] const LUENEBURG: [number, number] = [53.2464, 10.4115] @@ -352,6 +354,74 @@ describe('useRoadtripRoutes', () => { await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)) }) + /** + * Days read by the date each stop is REACHED, and the road between them. + * + * The arrangement itself is tested in `nightSpill.test.ts`; what is asserted here is + * what the hook does with it — the roads it has to ask for that a day-at-a-time plan + * never needed, and what the switch over them turns on. + */ + describe('driving that crosses a day boundary', () => { + /** Two days, the first of which sets off late enough to arrive after midnight. */ + const overnight = () => ({ + days: [day(1, 1), day(2, 2)], + assignments: { + ...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]), + } as AssignmentsMap, + }) + + it('FE-ROADTRIP-ROUTES-017: routes the road onto the day a night drive lands on', async () => { + // The stop reached after midnight is drawn under day 2, next to day 2's own first + // stop. Those two were never neighbours before, so their road was never asked for — + // and a chain with a hole in it draws as two runs with a gap across the middle. + const { days, assignments } = overnight() + // Three hours per leg, which is what puts the second stop past midnight. + calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }] }) + + const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments)) + await waitFor(() => expect(result.current.loading).toBe(false)) + await waitFor(() => expect(calculateRouteWithLegs.mock.calls.length).toBeGreaterThan(2)) + + // A two-point request, which is the seam rather than either day's own run. + const seam = calculateRouteWithLegs.mock.calls.find(c => c[0].length === 2 + && c[0][0].lat === LUENEBURG[0] && c[0][1].lat === BERLIN[0]) + expect(seam).toBeTruthy() + }) + + it('FE-ROADTRIP-ROUTES-018: leaves the gap between two ordinary days alone', async () => { + // Nothing moved here, so the road from one day's last stop to the next day's first + // is a gap the plan simply has. Asking for it is what the switch is for. + const daysList = [day(1, 1), day(2, 2)] + const assignments = { + ...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]), + } as AssignmentsMap + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.loading).toBe(false)) + + // One request per day and no more: the seam is not asked for. + expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2) + expect(result.current.days).toHaveLength(2) + }) + + it('FE-ROADTRIP-ROUTES-019: hands the map a line per leg, coloured by the day it is driven on', async () => { + const daysList = [day(1, 1), day(2, 2)] + const assignments = { + ...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]), + } as AssignmentsMap + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.loading).toBe(false)) + + // Same length and same order as `lines`, which is the contract the map paints from. + expect(result.current.lineDays).toHaveLength(result.current.lines.length) + expect(result.current.lineDays).toEqual([1, 2]) + }) + }) + it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => { const days = [day(1, 1)] const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }] @@ -368,4 +438,231 @@ describe('useRoadtripRoutes', () => { unmount() expect(seen!.aborted).toBe(true) }) + + it('FE-ROADTRIP-ROUTES-020: gives each leg its share of the drawn line, not its share of the routed metres', async () => { + // The router reports 50 km a leg off its own graph while the polyline it sends back + // measures 260 km as great-circle hops between the vertices. Cutting at the raw + // metres left every run short by the difference: the second leg ended a third of the + // way into the drive and the day never reached Berlin at all. + const days = [day(1, 1)] + const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }] + calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, LUENEBURG, BERLIN])) + const metres = (line: [number, number][]): number => lineMetres(line.map(([lat, lng]) => ({ lat, lng }))) + + const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops))) + await waitFor(() => expect(result.current.loading).toBe(false)) + + const [first, second] = result.current.lines + expect(first[0]).toEqual(HAMBURG) + // Both ends are interpolated inside the segment they fall in, so the two pieces meet + // on one coordinate and put back together give the run back. + expect(first[first.length - 1]).toEqual(second[0]) + // And the last cut lands on the last vertex rather than somewhere short of it. + expect(second[second.length - 1][0]).toBeCloseTo(BERLIN[0], 6) + expect(second[second.length - 1][1]).toBeCloseTo(BERLIN[1], 6) + // Two legs the router called equal take equal shares of the road that was drawn, and + // between them the whole 260 km of it rather than the 100 km the router reported. + const drawn = metres(first) + metres(second) + expect(metres(first) / drawn).toBeCloseTo(0.5, 2) + expect(drawn).toBeCloseTo(metres([HAMBURG, LUENEBURG, BERLIN]), -2) + }) + + /** + * The switch that turns a trip stored as days into one continuous drive. + * + * Off, a card holds only the driving between its own stops: the road across the join is + * not asked for, not drawn and not counted. On, every one of those gaps becomes a leg + * like any other — and it is the one stretch of a trip that no day run ever covers, so + * a hole left in the chain here is left there by nothing else. + */ + describe('connecting the days', () => { + beforeEach(() => { + // Wholesale, so nothing a previous case set can answer for this one. + useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }) + }) + + /** The hook reads the switch off the settings store, the way the limits card writes it. */ + const setting = (over: Partial): void => { + act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) }) + } + + /** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */ + const twoDays = () => ({ + days: [day(1, 1), day(2, 2)], + assignments: { + ...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]), + } as AssignmentsMap, + }) + + /** Every request for one particular road, found by the two ends it was asked for. */ + const askedFor = (from: [number, number], to: [number, number]) => + calculateRouteWithLegs.mock.calls.filter(c => + c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0]) + + /** A run whose legs are a round 100 km each, so the range budget reads off the page. */ + const hundreds = (legs: number, coordinates: [number, number][]) => ({ + coordinates, + distance: legs * 100000, + duration: legs * 3600, + legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })), + }) + + it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => { + const { days: daysList, assignments } = twoDays() + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.loading).toBe(false)) + + // A gap the day-at-a-time plan simply has, and the totals say so by leaving it out. + expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0) + expect(result.current.lines).toHaveLength(2) + expect(result.current.totalDistance).toBe(200000) + + setting({ roadtrip_connect_days: true }) + await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)) + await waitFor(() => expect(result.current.lines).toHaveLength(3)) + expect(result.current.totalDistance).toBe(300000) + }) + + it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => { + setting({ roadtrip_connect_days: true }) + const { days: daysList, assignments } = twoDays() + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.lines).toHaveLength(3)) + + // Same length and same order as `lines`, or a colour lands on the wrong day. + expect(result.current.lineDays).toHaveLength(result.current.lines.length) + // The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2 + // look like it begins somewhere it has no stop. + expect(result.current.lineDays).toEqual([1, 1, 2]) + // The kilometres go the other way: they are driven on the day they arrive. + expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000]) + }) + + it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => { + // Stored inside one day and real driving, but on screen it runs from a stop on one + // card to a stop on the next, and a line between two cards is what the switch asks + // about. Its kilometres belong to the day it lands on either way. + const daysList = [day(1, 1), day(2, 2)] + const assignments = { + ...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]), + } as AssignmentsMap + // Three hours a leg, which is what puts the second stop past midnight. + calculateRouteWithLegs.mockResolvedValue({ + ...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }], + }) + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.lines).toHaveLength(2)) + expect(result.current.days[0].distance).toBe(200000) + + setting({ roadtrip_connect_days: true }) + expect(result.current.lines).toHaveLength(3) + expect(result.current.lineDays).toEqual([1, 2, 2]) + expect(result.current.days[0].distance).toBe(300000) + }) + + it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => { + setting({ roadtrip_connect_days: true }) + const { days: daysList, assignments } = twoDays() + // Filed after the last stop of day 1, which is the join itself. Handed over in the + // wrong order on purpose: driven through its points backwards the seam doubles back. + const vias = { + 1: [ + { id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 }, + { id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }, + ], + } + // A shaped seam comes back as one leg per waypoint pair. + const inPieces = { + ...routed(1), + legs: [ + { distance: 30000, duration: 1000, text: '30 km' }, + { distance: 30000, duration: 1000, text: '30 km' }, + { distance: 40000, duration: 1600, text: '40 km' }, + ], + } + calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => + Promise.resolve(wp.length === 4 ? inPieces : routed(1))) + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias)) + await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)) + + const [waypoints] = askedFor(LUENEBURG, BERLIN)[0] + expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]]) + // Three pieces, one drive: the rail carries the whole road across the join rather + // than the first stretch of it. + await waitFor(() => expect(result.current.days[1].distance).toBe(200000)) + }) + + it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => { + // The first answer is filed under the pair of stops. Skipping the seam whenever any + // answer existed is what made a via on the join do visibly nothing at all. + setting({ roadtrip_connect_days: true }) + const { days: daysList, assignments } = twoDays() + const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] } + + const { rerender } = renderHook( + ({ v }: { v: Record }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v), + { initialProps: { v: vias } }, + ) + await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)) + + rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } }) + await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2)) + const [waypoints] = askedFor(LUENEBURG, BERLIN)[1] + expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 }) + }) + + it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => { + setting({ roadtrip_connect_days: true }) + const { days: daysList, assignments } = twoDays() + calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => + wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1))) + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)) + await waitFor(() => expect(result.current.loading).toBe(false)) + // Let the refusal settle: what it must not do is file a leg anyway. + await act(async () => { await Promise.resolve() }) + + expect(result.current.lines).toHaveLength(2) + expect(result.current.lineDays).toEqual([1, 2]) + // Partial rather than invented, exactly like any other leg that will not route. + expect(result.current.totalDistance).toBe(200000) + }) + + it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => { + // A tank does not empty overnight and the join is real driving, so the day it + // reaches starts with those kilometres already on the clock — which moves the point + // the fuel runs out a whole leg earlier. + const SOUTH: [number, number] = [51.5, 13.5] + const FURTHER_SOUTH: [number, number] = [51.05, 13.74] + setting({ roadtrip_range_km: 250 }) + const daysList = [day(1, 1), day(2, 2)] + const assignments = { + ...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: SOUTH }, { id: 5, at: FURTHER_SOUTH }]), + } as AssignmentsMap + calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve( + wp[0].lat === BERLIN[0] + ? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH]) + : hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG]))) + + const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + await waitFor(() => expect(result.current.days).toHaveLength(2)) + await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1)) + // 100 km carried over from day 1, so the 250th is reached halfway down the second leg. + expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 }) + + setting({ roadtrip_connect_days: true }) + await waitFor(() => expect(result.current.days[1].distance).toBe(300000)) + const [dry] = result.current.days[1].dryPoints! + expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 }) + // Walked along the roads this day drives, not along the drawn line: 50 km down that + // one is still on the join, north of Berlin, a whole leg from where the tank empties. + expect(dry.lat).toBeLessThan(BERLIN[0]) + }) + }) }) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 753b58fa2..85fed61f9 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -2,18 +2,30 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' import { - computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, - type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, -} from './roadtripModel' -import { projectOntoRoute } from './corridor' + deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint } from './roadtripModel' +import { projectOntoRoute, pointAtMeters, sliceAtMeters, lineMetres } from './corridor' +import { spillChains, type SpillMark } from './nightSpill' import { useSettingsStore } from '../../store/settingsStore' -import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' +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' /** One stop on a day's drive — an assignment whose place actually has coordinates. */ export interface RoadtripStop { assignmentId: number + /** + * The day this stop is STORED on, and its position there. + * + * A road trip draws its stops by the day they are reached, which after a drive across + * midnight is not the day they are stored on — see `nightSpill.ts`. Nothing is written + * to make that so, which means every callback that reorders, moves, re-anchors or adds + * beside a stop has to name the day the server knows it by. That is this pair, and it + * travels with the stop so no caller has to work it out. + */ + ownerDayId: number + ownerIndex: number placeId: number name: string lat: number @@ -27,6 +39,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. * @@ -53,6 +76,27 @@ 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[] + /** + * 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 })[] + /** + * `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 @@ -71,8 +115,23 @@ export interface RoadtripDay { driveWarnings: ScheduleWarning[] /** More driving in this day than the traveller allows, or null. */ dayWarning: DayWarning | null + /** + * Stretches of this day's chain that were driven onto it from an earlier day. + * + * Empty on an ordinary day, which is most of them. See `nightSpill.ts`: a drive that + * crosses midnight is read on the date it ARRIVES, and the stops it reaches are drawn + * here even though they are stored on the day they set off from. Each stop still names + * that day in `ownerDayId`, so nothing that writes has to know about this. + */ + spills?: SpillMark[] } +/** + * A day with its stops and nothing routed onto it yet — what the routing effect is handed + * and what the arrangement by date is worked out from. + */ +type PlanDay = Pick + /** A place, the road it is driven to from, and how far apart the two are. */ export interface AccessSpur { line: [[number, number], [number, number]] @@ -104,6 +163,14 @@ export interface RoadtripRoutes { quietDays: QuietDay[] /** One polyline per routed leg, in trip order — what the map draws in road trip mode. */ lines: [number, number][][] + /** + * The day each entry of `lines` is driven on, same length and same order. + * + * So the map can colour the trip by day without knowing anything about how the days + * were worked out. A night drive belongs to the day it ARRIVES on, the same rule its + * kilometres follow. + */ + lineDays: number[] /** * The gap between a place and the road it was routed from, as a two-point line. * @@ -190,14 +257,30 @@ export interface SnappedPoint { const stopKey = (s: RoadtripStop): string => `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}` +/** + * The via points that shape the drive leaving a stop, as one comparable string. + * + * Read in two places that must agree: when deciding whether a seam still matches the + * answer already in hand, and when recording what an answer was fetched for. One function + * so the two cannot drift and quietly stop refetching. + */ +const seamShape = (from: RoadtripStop, viasByDay: Record): string => + (viasByDay[from.ownerDayId] ?? []) + .filter(v => v.after_order_index === from.ownerIndex) + .sort((a, b) => a.sequence - b.sequence) + .map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`) + .join('|') + /** The drive from one stop to the next, identified the same way `planKey` identifies them. */ const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}` -const asStop = (a: Assignment): RoadtripStop | null => { +const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => { const p = a.place if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null return { assignmentId: a.id, + ownerDayId, + ownerIndex, placeId: a.place_id, name: p.name, lat: p.lat, @@ -207,6 +290,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, } } @@ -247,42 +331,81 @@ 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>({}) + /** + * Drives that only exist because a night moved a stop onto the next day's card. + * + * The trip is routed a stored day at a time, so the road between the last stop of one + * day and the first of the next was never asked for — there was no chain that needed + * it. Once a drive across midnight hands its stops forward, the stop it ends at and the + * next day's own first stop become NEIGHBOURS in one chain, and a chain with a hole in + * it draws two disconnected runs on the map. Filed by leg key like every other leg, so + * everything downstream reads them without knowing where they came from. + */ + const [seamLegs, setSeamLegs] = 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) + const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent) + /** + * 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) + /** + * Whether the gaps between days are driven too. + * + * A trip is stored as days and routed as days, so the road from one day's last stop to + * the next day's first was never asked for: no chain needed it. Turning this on makes + * the trip one continuous drive — every one of those gaps is routed, drawn, and counted + * towards the day it ARRIVES on, which is the same rule a night drive already follows. + */ + const connectDays = useSettingsStore(s => !!s.settings.roadtrip_connect_days) + const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting]) + // 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. const limits = useMemo( - () => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }), - [legMinutes, dayMinutes, rangeKm], + () => ({ + legMinutes: legMinutes || null, + dayMinutes: dayMinutes || null, + rangeKm: planningRangeKm, + // Only a real fraction counts. Zero, absent and 100 all mean "fills right up", + // which is what the budget did before the setting existed. + fillPercent: fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : null, + }), + [legMinutes, dayMinutes, planningRangeKm, fillPercent], ) - const plan = useMemo(() => { + const plan = useMemo(() => { return [...days] .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) .map(d => { const stops = (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(asStop) + .map(a => asStop(a, d.id, 0)) .filter((s): s is RoadtripStop => s !== null) + // The index is filled in after the drop, because it is the index into THIS + // list: an assignment whose place has no coordinates never becomes a stop, and + // counting before the filter would name a row the rail does not draw. + .map((s, i) => ({ ...s, ownerIndex: i })) return { dayId: d.id, dayNumber: d.day_number ?? 0, date: d.date ?? null, title: d.title ?? null, stops, - legs: [], - legVias: [], - driveWarnings: [], - dayWarning: null, - schedule: { entries: [], warnings: [] }, - geometry: [], - distance: 0, - duration: 0, } }) .filter(d => d.stops.length > 1) @@ -299,8 +422,9 @@ export function useRoadtripRoutes( stops: (assignments[String(d.id)] ?? []) .slice() .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) - .map(asStop) - .filter((s): s is RoadtripStop => s !== null), + .map(a => asStop(a, d.id, 0)) + .filter((s): s is RoadtripStop => s !== null) + .map((s, i) => ({ ...s, ownerIndex: i })), })) .filter(d => d.stops.length < 2) }, [days, assignments]) @@ -327,6 +451,7 @@ export function useRoadtripRoutes( if (!plan.length) { setLegsByDay({}) setSnapByDay({}) + setMissedByDay({}) setLoading(false) return } @@ -339,6 +464,7 @@ export function useRoadtripRoutes( const collected: Record> = {} const collectedSnaps: Record> = {} + const collectedMisses: Record = {} const tasks: (() => Promise)[] = [] for (const day of plan) { @@ -353,7 +479,6 @@ export function useRoadtripRoutes( dfMode, )) - const dayVias = viasByDay[day.dayId] ?? [] for (const { stops: run, mode } of runs) { tasks.push(async () => { @@ -367,9 +492,14 @@ export function useRoadtripRoutes( stopAt.push(waypoints.length) waypoints.push({ lat: stop.lat, lng: stop.lng }) if (i === run.length - 1) return - const dayIndex = day.stops.indexOf(stop) - dayVias - .filter(v => v.after_order_index === dayIndex) + // A via is filed against the day it was dropped on and the position it sits + // after IN THAT DAY. Since a chain can hold stops from an earlier day (see + // `nightSpill.ts`), the lookup has to be the stop's own day and its own + // index — this used to be the position within the chain, so on any day that + // received a night drive every via matched nothing and quietly stopped + // shaping the road. + ;(viasByDay[stop.ownerDayId ?? day.dayId] ?? []) + .filter(v => v.after_order_index === (stop.ownerIndex ?? i)) .sort((a, b) => a.sequence - b.sequence) .forEach(v => waypoints.push({ lat: v.lat, lng: v.lng })) }) @@ -379,7 +509,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 @@ -406,6 +536,18 @@ export function useRoadtripRoutes( // together would get wrong. const legEndMeters: number[] = [] const legKeys: string[] = [] + /** + * Where each leg starts and ends along the run, so its own stretch of road + * can be cut out of the run's one polyline. + * + * Collected rather than cut on the spot because the cut is proportional: + * the router reports a leg's length off its own graph while the polyline is + * great-circle hops between the vertices it sent back, and the two never + * agree exactly. Cutting at absolute metres left the last leg of every run + * short by the difference. Cutting at the same FRACTION of each makes the + * last cut land on the last vertex, so the pieces still rebuild the run. + */ + const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = [] let travelled = 0 for (let i = 0; i < run.length - 1; i++) { const from = run[i] @@ -418,11 +560,26 @@ export function useRoadtripRoutes( distance: parts.reduce((sum, l) => sum + (l.distance ?? 0), 0), duration: parts.reduce((sum, l) => sum + (l.duration ?? 0), 0), } + const startedAt = travelled travelled += merged.distance ?? 0 legEndMeters.push(travelled) const key = legKey(from, to) legKeys.push(key) - dayLegs[key] = { seg: { ...merged, mode }, line: i === 0 ? r.coordinates : [], vias: [] } + cuts.push({ key, from: startedAt, to: travelled, seg: { ...merged, mode } }) + } + // The road under each leg, in the polyline's own measure. It used to be + // "the whole run hangs off leg 0", which was enough while a day was always + // drawn whole — but a drive across midnight hands its stops to the next + // card and the road has to travel with them. + const drawnMetres = lineMetres(spine) + const scale = travelled > 0 ? drawnMetres / travelled : 0 + for (const cut of cuts) { + dayLegs[cut.key] = { + seg: cut.seg, + line: sliceAtMeters(spine, cut.from * scale, cut.to * scale) + .map(pt => [pt.lat, pt.lng] as [number, number]), + vias: [], + } } for (const { via, alongMeters } of viaAlong) { const idx = legIndexForAlong(legEndMeters, alongMeters) @@ -456,6 +613,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. @@ -466,73 +624,298 @@ export function useRoadtripRoutes( })() return () => controller.abort() - // planKey stands in for `plan`: same geometry, same legs. + // planKey stands in for `plan`: same geometry, same legs. avoidKey, not `avoid`: + // a fresh array every render would re-route on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [planKey, routeProfile, distanceUnit, tripId]) + }, [planKey, routeProfile, distanceUnit, tripId, avoidKey]) + + /** + * Every leg known so far, by the two stops it connects. + * + * Filed under a key built from the stops' own coordinates, so which day it was fetched + * under stops mattering the moment a drive across midnight moves its stops to the next + * card — the road between two places is the road between them whichever date it is + * driven on. + */ + const allLegs = useMemo(() => { + const out: Record = {} + for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {}) + return { ...out, ...seamLegs } + }, [plan, legsByDay, seamLegs]) + + // Which date each stop is actually reached on. Nothing is written to make it so; see + // `nightSpill.ts`. + const chains = useMemo( + () => spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]), + [plan, quietDays, allLegs], + ) + + /** + * Pairs a chain needs a road for and does not have. + * + * Only ever a seam between two stored days: everything inside one day was routed as a + * run. Left unrouted the rail shows a chain that stops halfway and the map draws the + * day in two pieces with a gap across the middle. + */ + const seams = useMemo(() => { + const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = [] + const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => { + // Routed as part of a day's own run — that request is rebuilt whenever its vias + // change, so there is nothing to catch up here. + if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return + // A seam already fetched is skipped only while it was fetched for the SHAPE it has + // now. Skipping it whenever any answer existed is what made a via on a seam do + // nothing at all: the first answer was cached under the pair, and dragging the + // point changed the request nobody was going to send again. + const have = seamLegs[legKey(from, to)] + if (have && have.shape === seamShape(from, viasByDay)) return + out.push({ from, to, dayId }) + } + for (const chain of chains) { + for (let i = 0; i < chain.stops.length - 1; i++) { + const from = chain.stops[i] + const to = chain.stops[i + 1] + // Inside one stored day it was routed as part of that day's run. + if (from.ownerDayId === to.ownerDayId) continue + want(from, to, chain.dayId) + } + } + // And, when the traveller asks for one continuous drive, the road from each card's + // last stop to the next card's first — the one gap a day-at-a-time routing leaves. + if (connectDays) { + for (let d = 0; d < chains.length - 1; d++) { + const from = chains[d].stops[chains[d].stops.length - 1] + const to = chains[d + 1].stops[0] + if (from && to) want(from, to, chains[d + 1].dayId) + } + } + return out + }, [chains, legsByDay, seamLegs, viasByDay, connectDays]) + const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';') + + useEffect(() => { + if (!seams.length) return + const controller = new AbortController() + void (async () => { + for (const seam of seams) { + if (controller.signal.aborted) return + const mode = resolveLegMode( + { isPlace: true, leg_transport_mode: seam.from.legMode }, + { isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode }, + days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile, + ) + try { + // The via points on this seam, threaded in the same way the day runs thread + // theirs. Without them a seam is the one stretch of the trip a via cannot + // shape: it is asked for on its own, so the points the traveller dropped on it + // never reached the router and dragging one did visibly nothing. + const shaping = (viasByDay[seam.from.ownerDayId] ?? []) + .filter(v => v.after_order_index === seam.from.ownerIndex) + .sort((a, b) => a.sequence - b.sequence) + const r = await calculateRouteWithLegs( + [ + { lat: seam.from.lat, lng: seam.from.lng }, + ...shaping.map(v => ({ lat: v.lat, lng: v.lng })), + { lat: seam.to.lat, lng: seam.to.lng }, + ], + { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid }, + ) + if (controller.signal.aborted) return + // One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail + // wants the whole drive: summed here, exactly as a day run folds its via legs + // back onto the stop pair they belong to. + if (!r.legs.length) continue + const merged = r.legs.length === 1 ? r.legs[0] : { + ...r.legs[0], + distance: r.legs.reduce((sum, l) => sum + (l.distance ?? 0), 0), + duration: r.legs.reduce((sum, l) => sum + (l.duration ?? 0), 0), + } + setSeamLegs(prev => ({ + ...prev, + [legKey(seam.from, seam.to)]: { + seg: { ...merged, mode }, + line: r.coordinates, + vias: [], + shape: seamShape(seam.from, viasByDay), + }, + })) + } catch { + // A seam that will not route stays missing, exactly like any other leg that + // will not route: the chain shows a gap and the totals are partial rather than + // invented. Retried on the next run of this effect. + if (controller.signal.aborted) return + } + await sleep(REQUEST_SPACING_MS, controller.signal) + } + })() + return () => controller.abort() + // seamKey stands in for `seams`: same pairs, same requests. avoidKey, not `avoid`: + // a fresh array every render would re-ask for every seam on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seamKey, routeProfile, tripId, avoidKey]) return useMemo(() => { + // Legs and snaps, flat. They are filed under a key built from the two stops' own + // coordinates, so which day they were fetched under stops mattering the moment a + // drive across midnight moves its stops to the next card — the road between two + // places is the road between them whichever date it is driven on. + const allSnaps: Record = {} + for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {}) + const legFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)] + const lines: [number, number][][] = [] + const lineDays: number[] = [] const segments: RouteSegment[] = [] const accessLines: RoadtripRoutes['accessLines'] = [] // A loop rather than a map, because the range budget outlives a single day: a tank // does not empty overnight, so what is left at the end of one day is what the next - // one starts with. `plan` is already in day order. + // one starts with. `chains` is already in day order, and the legs inside them are in + // driving order, which is the order the tank is emptied in. const out: RoadtripDay[] = [] let carryKm: number | null = 0 - for (const day of plan) { - const dayLegs = legsByDay[day.dayId] ?? {} - const daySnaps = snapByDay[day.dayId] ?? {} - const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])]) - for (const leg of routed) { + /** The stop the previous card ended at, so the next one can be driven to from it. */ + let previousStop: RoadtripStop | undefined + /** And which day that was, so the road out of it is drawn as that day's. */ + let previousDayNumber: number | undefined + for (const chain of chains) { + const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1])) + /** + * The drives that reach this card rather than leave from it, and only when the + * traveller asked for them. + * + * Every one of these crosses from one card to the next: a night drive that handed + * its stops forward, or plainly the road from yesterday's last stop to today's + * first. Whether a road trip is one continuous line or a day at a time is exactly + * what the switch asks, so with it off a card holds only the driving between its + * own stops — no stroke across the join, and none of those kilometres in its total. + * + * A night drive is no exception. It is stored inside one day and it is real + * driving, but on screen it runs from a stop on one card to a stop on the next, and + * a line between two cards is the thing the switch is about. + * + * `drawnAs` is the day it LEAVES rather than the one it arrives on. The kilometres + * count towards the arrival, because that is when they are driven, but the stroke + * starts at a stop on yesterday's card and colouring it as tomorrow makes tomorrow + * look like it begins at a stop that is not on it. + */ + const inboundAt = new Map() + if (connectDays) { + for (const spill of chain.spills) { + inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber }) + } + // And the road out of the previous card where no night drive already opens this + // one. Nothing moved for that one: it is the gap a day-at-a-time plan leaves. + const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0]) + if (joined) inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber }) + } + previousStop = chain.stops[chain.stops.length - 1] ?? previousStop + previousDayNumber = chain.stops.length ? chain.dayNumber : previousDayNumber + for (let i = 0; i < chain.stops.length; i++) { + const inbound = inboundAt.get(i) + if (inbound) { + if (inbound.line.length > 1) { lines.push(inbound.line); lineDays.push(inbound.drawnAs) } + if (inbound.seg) segments.push(inbound.seg) + } + const leg = routed[i] if (!leg) continue - if (leg.line.length > 1) lines.push(leg.line) + if (leg.line.length > 1) { lines.push(leg.line); lineDays.push(chain.dayNumber) } segments.push(leg.seg) } - // The run polylines end to end: leg 0 of a run carries its whole geometry, the - // rest carry none, so concatenating what is there gives the day as driven. - const geometry = routed.flatMap(l => l?.line ?? []) + // Each leg carries its own stretch of road now, so the day's line is simply its + // legs end to end — with the night that reached it threaded in at the stop it + // arrives at, so the line is drawn in the order it is driven. + const geometry: [number, number][] = [] + for (let i = 0; i < chain.stops.length; i++) { + const inbound = inboundAt.get(i) + if (inbound) geometry.push(...inbound.line) + geometry.push(...(routed[i]?.line ?? [])) + } const legs = routed.map(l => l?.seg) + const inbound = [...inboundAt.values()].map(l => l.seg) const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0) + + inbound.reduce((sum, l) => sum + (l?.distance ?? 0), 0) const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0) - const schedule = computeSchedule( - day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), - legs.map(l => l?.duration), - ) + + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0) + const schedule = chain.schedule const legVias = routed.map(l => l?.vias ?? []) - const stops = day.stops.map(s => { - const snap = daySnaps[stopKey(s)] + const stops = chain.stops.map(s => { + const snap = allSnaps[stopKey(s)] const line = spurFor(snap) if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) }) return { ...s, offRoadMeters: line ? snap.meters : null } }) + // The night drive is on the tank too, and it is in no leg list: it ends at this + // chain's first stop rather than leaving from it. Added to the carry so the budget + // has spent it before the first stop of the day gets a chance to fill up again. + const inboundKm = inbound + .filter(l => l && (l.mode === undefined || l.mode === 'driving')) + .reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000 + const startKm = carryKm === null ? null : carryKm + inboundKm const drive = deriveDriveWarnings( 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, + startKm, + // 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. + // + // Walked along the DRIVING legs only. `geometry` above is every leg 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) + // 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, + dayId: chain.dayId, + dayNumber: chain.dayNumber, + date: chain.date, + title: chain.title, + avoidMissed: missedByDay[chain.dayId], + spills: chain.spills, + stops, legs, legVias, schedule, geometry, distance, duration, dryPoints, drivingGeometry, driveWarnings: drive.warnings, dayWarning: drive.day, }) } + const drives = out.filter(d => d.stops.length > 1) return { - days: out, + days: drives, lines, + lineDays, segments, accessLines, vias: out.flatMap(d => d.legVias.flat()), - totalDistance: out.reduce((s, d) => s + d.distance, 0), - totalDuration: out.reduce((s, d) => s + d.duration, 0), + totalDistance: drives.reduce((s, d) => s + d.distance, 0), + totalDuration: drives.reduce((s, d) => s + d.duration, 0), + // Over the days the head is a summary OF, which is the ones with a drive: a day + // holding a single stop draws no card and contributes nothing else here either. // Service stops are not stops in this sense — a charger on the way is part of the // drive, and counting it here would make the head disagree with the day cards. - totalStops: out.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0), - quietDays, + totalStops: drives.reduce((s, d) => s + d.stops.filter(st => !isServiceStopType(st.stopType)).length, 0), + // A day left with one stop or none draws no drive, so it stays what it was: an + // outline that appears while a stop is in flight and is not worth a row otherwise. + quietDays: out + .filter(d => d.stops.length < 2) + .map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), loading, } - }, [plan, legsByDay, snapByDay, loading, quietDays, limits]) -} + }, [plan, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays]) +} \ No newline at end of file diff --git a/client/src/components/Roadtrip/useRoadtripVias.test.ts b/client/src/components/Roadtrip/useRoadtripVias.test.ts index e32da76ad..b8b605bcd 100644 --- a/client/src/components/Roadtrip/useRoadtripVias.test.ts +++ b/client/src/components/Roadtrip/useRoadtripVias.test.ts @@ -11,13 +11,43 @@ const { roadtripApi } = vi.hoisted(() => ({ })) vi.mock('../../api/client', () => ({ roadtripApi })) +// Offline is the hook's headline rule and it has no other way in: `editable` is read +// straight off useNetworkMode, so a test that never drives it can only ever see the +// online answer. +const { networkMode } = vi.hoisted(() => ({ networkMode: { offline: false } })) +vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => networkMode })) + +// The remote path is driven by hand: the hook hands its handler to addListener, and the +// cases below call it the way a message from another screen would arrive. +const { addListener, removeListener } = vi.hoisted(() => ({ + addListener: vi.fn(), + removeListener: vi.fn(), +})) +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), + addListener, + removeListener, +})) + import { useRoadtripVias } from './useRoadtripVias' const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over, }) +/** Whatever the hook last gave the socket, which is how a change from another screen gets in. */ +const socketHandler = (): ((event: Record) => void) => { + const call = addListener.mock.calls[addListener.mock.calls.length - 1] + if (!call) throw new Error('the hook never subscribed to the socket') + return call[0] +} + beforeEach(() => { + networkMode.offline = false roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] }) roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() }) roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() }) @@ -107,4 +137,141 @@ describe('useRoadtripVias', () => { expect(roadtripApi.addVia).not.toHaveBeenCalled() }) +it('FE-ROADTRIP-VIAS-008: a via somebody else moved replaces that day and only that day', async () => { + // The screen holds the id the router handed it, a string, and the socket sends the + // number the server broadcasts. Compared as they arrive, every remote change is lost. + roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] }) + const { result } = renderHook(() => useRoadtripVias('7', true)) + await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1)) + const untouched = result.current.byDay[1] + + // dayId travels as the route param it came from, so it is a string on the wire too. + act(() => socketHandler()({ + type: 'roadtripVia:changed', + tripId: 7, + dayId: '2', + vias: [via({ id: 8, day_id: 2, lat: 48 }), via({ id: 9, day_id: 2, lat: 49 })], + })) + + // The server sends the day's whole list, so this replaces rather than merges: a merge + // keeps drawing the point the other screen has just deleted. + expect(result.current.byDay[2].map(v => v.id)).toEqual([8, 9]) + expect(result.current.byDay[1]).toBe(untouched) + }) + + it('FE-ROADTRIP-VIAS-009: a change to another trip is not applied to this one', async () => { + roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] }) + const { result } = renderHook(() => useRoadtripVias(7, true)) + await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1)) + const before = result.current.byDay + + act(() => socketHandler()({ + type: 'roadtripVia:changed', tripId: 8, dayId: '1', vias: [via({ id: 4, day_id: 1 })], + })) + + // The same object, not merely an equal one: this list feeds the routing key, so even + // handing back a fresh copy re-routes the trip for an event meant for somebody else. + expect(result.current.byDay).toBe(before) + }) + + it('FE-ROADTRIP-VIAS-010: a day emptied elsewhere leaves the map instead of lingering empty', async () => { + roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] }) + const { result } = renderHook(() => useRoadtripVias(7, true)) + await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1)) + + act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] })) + + // An empty list left behind still answers yes to a `dayId in byDay` check, which is + // how the surfaces ask whether a day is shaped at all. + expect(2 in result.current.byDay).toBe(false) + expect(result.current.byDay[1]).toHaveLength(1) + + const before = result.current.byDay + act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] })) + // Nothing left to drop, so nothing new to hand out — a copy here would re-route the + // whole trip for an event that changed nothing. + expect(result.current.byDay).toBe(before) + }) + + it('FE-ROADTRIP-VIAS-011: only a track event moves the track, and it clears just its own day', async () => { + roadtripApi.listVias.mockResolvedValue({ + vias: [via({ id: 1, day_id: 1 })], + tracks: [{ day_id: 1, place_id: 11, stray_km: 0.4 }, { day_id: 2, place_id: 22, stray_km: null }], + }) + const { result } = renderHook(() => useRoadtripVias(7, true)) + await waitFor(() => expect(result.current.trackByDay[1]?.place_id).toBe(11)) + + // A via event carries no track at all. Letting it reach the track map would drop the + // badge for the road the day follows every time anybody nudged a point on it. + act(() => socketHandler()({ + type: 'roadtripVia:changed', tripId: 7, dayId: '1', vias: [via({ id: 3, day_id: 1 })], + })) + expect(result.current.trackByDay[1]?.place_id).toBe(11) + + act(() => socketHandler()({ type: 'roadtripTrack:changed', tripId: 7, dayId: '1', track: null })) + expect(1 in result.current.trackByDay).toBe(false) + expect(result.current.trackByDay[2]?.place_id).toBe(22) + + act(() => socketHandler()({ + type: 'roadtripTrack:changed', tripId: 7, dayId: '2', track: { day_id: 2, place_id: 33, stray_km: 1 }, + })) + expect(result.current.trackByDay[2].place_id).toBe(33) + }) + + it('FE-ROADTRIP-VIAS-012: the listener follows the trip and goes down with the screen', async () => { + roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] }) + const { result, rerender, unmount } = renderHook(({ id }: { id: number }) => useRoadtripVias(id, true), { + initialProps: { id: 7 }, + }) + await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1)) + const first = socketHandler() + + rerender({ id: 8 }) + await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledWith(8)) + const second = socketHandler() + expect(second).not.toBe(first) + expect(removeListener).toHaveBeenCalledWith(first) + + // The live handler is the one built for the trip on screen. One kept from the previous + // trip would keep writing that trip's days into this one. + act(() => second({ + type: 'roadtripVia:changed', + tripId: 7, + dayId: '1', + vias: [via({ id: 4, day_id: 1 }), via({ id: 5, day_id: 1 })], + })) + expect(result.current.byDay[1]).toHaveLength(1) + + // A handler that outlives its screen writes into a trip nobody is looking at. + unmount() + expect(removeListener).toHaveBeenCalledWith(second) + }) + + it('FE-ROADTRIP-VIAS-014: with no network the map is told not to offer a drag', async () => { + // A via anchors to a POSITION in the day stop list, not to an id, so a write queued + // offline and replayed after somebody else added a stop would re-pin the drive onto + // a leg nobody chose, silently and hours later. Until the anchor is an assignment id + // the honest answer is to stop offering the gesture rather than to lose the edit one + // write at a time. + roadtripApi.listVias.mockResolvedValue({ vias: [] }) + networkMode.offline = true + + const { result } = renderHook(() => useRoadtripVias(7, true)) + + expect(result.current.editable).toBe(false) + }) + + it('FE-ROADTRIP-VIAS-013: an event that names no day is dropped rather than filed under NaN', async () => { + roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] }) + const { result } = renderHook(() => useRoadtripVias(7, true)) + await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1)) + const before = result.current.byDay + + act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, vias: [via({ id: 4, day_id: 1 })] })) + + // Unguarded this lands under the key NaN, and everything that walks the days then + // routes a day the trip does not have. + expect(result.current.byDay).toBe(before) + expect(Object.keys(result.current.byDay)).toEqual(['1']) + }) }) diff --git a/client/src/components/Roadtrip/useRoadtripVias.ts b/client/src/components/Roadtrip/useRoadtripVias.ts index 4dc8c6f0c..836c0d36e 100644 --- a/client/src/components/Roadtrip/useRoadtripVias.ts +++ b/client/src/components/Roadtrip/useRoadtripVias.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { addListener, removeListener } from '../../api/websocket' import { roadtripApi } from '../../api/client' import { useNetworkMode } from '../../hooks/useNetworkMode' import { isEmptyReanchoring, type Reanchoring } from './roadtripModel' @@ -66,7 +67,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. @@ -92,6 +93,14 @@ const EMPTY_TRACKS: Record = {} * patched by hand — a via has a server-assigned id and sequence, and guessing them would * be a second source of truth for the sake of one round trip. */ +/** A copy of the map without one day, so an emptied day does not linger as an empty list. */ +function omit(map: Record, dayId: number): Record { + if (!(dayId in map)) return map + const next = { ...map } + delete next[dayId] + return next +} + export function useRoadtripVias(tripId: number | string | null, active: boolean): RoadtripVias { const [byDay, setByDay] = useState>(EMPTY) const [trackByDay, setTrackByDay] = useState>(EMPTY_TRACKS) @@ -146,6 +155,36 @@ export function useRoadtripVias(tripId: number | string | null, active: boolean) useEffect(() => { void reload() }, [reload]) + /** + * What somebody else did to the drive, applied as it happens. + * + * Its own listener rather than a slice in the store, the way the collab tabs do it: + * these points live in this hook and nowhere else, and giving them a store slice would + * be a second copy of the same list to keep in step. + * + * The server sends the whole day's list, so applying it is a replace rather than a + * merge — and it excludes the socket that wrote, so a drag never gets its own point + * handed back mid-gesture. + */ + useEffect(() => { + if (!tripId || !active) return + const handler = (event: Record) => { + if (String(event.tripId) !== String(tripId)) return + const dayId = Number(event.dayId) + if (!Number.isFinite(dayId)) return + if (event.type === 'roadtripVia:changed') { + const vias = (event.vias ?? []) as RoadtripVia[] + setByDay(prev => (vias.length ? { ...prev, [dayId]: vias } : omit(prev, dayId))) + } + if (event.type === 'roadtripTrack:changed') { + const track = event.track as RoadtripDayTrack | null + setTrackByDay(prev => (track ? { ...prev, [dayId]: track } : omit(prev, dayId))) + } + } + addListener(handler) + return () => removeListener(handler) + }, [tripId, active]) + const add = useCallback(async (dayId: number, afterOrderIndex: number, lat: number, lng: number) => { if (!tripId) return await roadtripApi.addVia(tripId, dayId, { after_order_index: afterOrderIndex, lat, lng }) @@ -171,9 +210,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/components/Roadtrip/useVehicleRange.test.ts b/client/src/components/Roadtrip/useVehicleRange.test.ts new file mode 100644 index 000000000..9c2b823b0 --- /dev/null +++ b/client/src/components/Roadtrip/useVehicleRange.test.ts @@ -0,0 +1,199 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { useSettingsStore } from '../../store/settingsStore' +import type { Settings } from '../../types' +import { useVehicleRange } from './useVehicleRange' + +/** + * Seven settings assembled into one answer. + * + * The arithmetic is pinned next door in vehicleRange.test.ts; what is worth guarding + * here is the assembly: that an unrecognised word is no vehicle rather than a vehicle + * nobody can refuel, that every one of the parts really reaches the division, and that + * a range which rounds away arrives as no limit rather than as a zero one. + * + * The identity cases are worth less than they look, and are kept small for that reason. + * Every reader today destructures the object on the spot, so its reference reaches + * nothing; what they guard is the day a reader stops doing that. + */ + +// setState replaces the settings object wholesale, which is what the hook has to cope +// with anyway: the settings load hands it a new one on every fetch. +function setSettings(over: Partial): void { + act(() => { useSettingsStore.setState({ settings: over as never }) }) +} + +/** Changes one field and leaves the rest, the way the settings dialog does. */ +function changeSetting(over: Partial): void { + act(() => { + useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } as never })) + }) +} + +beforeEach(() => { + // Wholesale, so nothing a previous case stored can answer for this one. + useSettingsStore.setState({ settings: {} as never }) +}) + +describe('useVehicleRange', () => { + it('FE-VEHRANGEHOOK-001: a word the picker never wrote is no vehicle at all', () => { + // The setting is a free string on the wire and nothing on the server checks it, so a + // hand-edited value or a leftover from an older picker arrives here as-is. Anything + // but the two known words has to fall back to "not said", which is the state where a + // stop of either kind still counts as a fill-up. + setSettings({ roadtrip_vehicle: 'lorry' }) + expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull() + + setSettings({ roadtrip_vehicle: 'Electric' }) + expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull() + + setSettings({}) + expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBeNull() + }) + + it('FE-VEHRANGEHOOK-002: the two words it does know come back untouched', () => { + setSettings({ roadtrip_vehicle: 'combustion' }) + expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('combustion') + + setSettings({ roadtrip_vehicle: 'electric' }) + expect(renderHook(() => useVehicleRange()).result.current.vehicleKind).toBe('electric') + }) + + it('FE-VEHRANGEHOOK-003: a complete pair of parts beats the round number that was typed', () => { + // 55 litres at 7 per hundred is 786 km, and the traveller who filled both fields said + // something more exact than "about 500". + setSettings({ + roadtrip_vehicle: 'combustion', + roadtrip_tank_litres: 55, + roadtrip_litres_per_100: 7, + roadtrip_range_km: 500, + }) + + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(786) + }) + + it('FE-VEHRANGEHOOK-004: half a specification leaves the typed number standing', () => { + // Half a pair is not a range, and the typed figure is still a real answer — dropping + // it here would take the range budget away from somebody who had set one. + setSettings({ + roadtrip_vehicle: 'combustion', + roadtrip_tank_litres: 55, + roadtrip_range_km: 500, + }) + + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500) + }) + + it('FE-VEHRANGEHOOK-005: figures left over from the other kind of car are not used', () => { + // Someone who filled in a tank and then switched the picker to electric still has + // both petrol fields in their settings. Feeding them to an electric car would hand + // back a range that has nothing to do with the battery. + setSettings({ + roadtrip_vehicle: 'electric', + roadtrip_tank_litres: 55, + roadtrip_litres_per_100: 7, + roadtrip_range_km: 500, + }) + + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(500) + }) + + it('FE-VEHRANGEHOOK-006: the battery figures arrive complete, age included', () => { + // 58 kWh less a tenth is 52.2, and 52.2 at 16 per hundred is 326 km. Forgetting to + // forward the degradation would give 363 here, which is a warning that fires 37 km + // after the car has stopped. + setSettings({ + roadtrip_vehicle: 'electric', + roadtrip_battery_kwh: 58, + roadtrip_kwh_per_100: 16, + roadtrip_battery_degradation: 10, + }) + + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBe(326) + }) + + it('FE-VEHRANGEHOOK-007: an unset or zeroed range is no limit, never a range of nothing', () => { + // Zero is a legal thing to type into the field and means the same as leaving it + // empty. Handing a 0 downstream would put the dry point at the first metre and light + // up every stop on the trip. + setSettings({ roadtrip_range_km: 0 }) + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull() + + setSettings({}) + expect(renderHook(() => useVehicleRange()).result.current.rangeKm).toBeNull() + }) + + it('FE-VEHRANGEHOOK-008: a re-render with nothing changed hands back the same object', () => { + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 }) + const { result, rerender } = renderHook(() => useVehicleRange()) + + const first = result.current + rerender() + + // Identity, not equality: the readers put this straight into an effect dependency + // list, so a new object per render means the whole trip is re-routed per render. + expect(result.current).toBe(first) + }) + + it('FE-VEHRANGEHOOK-009: a setting this hook does not read causes no churn', () => { + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: false }) + const { result } = renderHook(() => useVehicleRange()) + const first = result.current + + // A fresh settings object arrives on every settings load and after every unrelated + // toggle, and the seven fields are read as primitives so none of that reaches here. + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500, dark_mode: true }) + + expect(result.current).toBe(first) + }) + + it('FE-VEHRANGEHOOK-011: every part of the car reaches the arithmetic while mounted', () => { + // Each field changed on a hook that is already mounted, which is the only way a + // missing memo dependency shows itself: mounting afresh recomputes regardless, so a + // suite built out of fresh mounts passes with half the dependency list deleted. + setSettings({ roadtrip_vehicle: 'electric', roadtrip_battery_kwh: 58, roadtrip_kwh_per_100: 16 }) + const { result } = renderHook(() => useVehicleRange()) + expect(result.current.rangeKm).toBe(363) + + changeSetting({ roadtrip_battery_kwh: 77 }) + expect(result.current.rangeKm).toBe(481) + + changeSetting({ roadtrip_kwh_per_100: 22 }) + expect(result.current.rangeKm).toBe(350) + + changeSetting({ roadtrip_battery_degradation: 10 }) + expect(result.current.rangeKm).toBe(315) + + // And the kind itself: the same figures mean nothing to a petrol car, which has no + // tank size stored here at all. + changeSetting({ roadtrip_vehicle: 'combustion' }) + expect(result.current.vehicleKind).toBe('combustion') + expect(result.current.rangeKm).toBeNull() + + changeSetting({ roadtrip_tank_litres: 55, roadtrip_litres_per_100: 7 }) + expect(result.current.rangeKm).toBe(786) + }) + + it('FE-VEHRANGEHOOK-012: a range that rounds away leaves no limit rather than a zero one', () => { + // The branch this hook owns, as opposed to the one its neighbour owns: parts that + // round to nothing must not arrive as the number 0, which every reader downstream + // would take for a car that cannot leave the drive. + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_tank_litres: 1, roadtrip_litres_per_100: 500 }) + const { result } = renderHook(() => useVehicleRange()) + + expect(result.current.rangeKm).toBeNull() + }) + + it('FE-VEHRANGEHOOK-010: a range the traveller actually changed does come through', () => { + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 500 }) + const { result } = renderHook(() => useVehicleRange()) + const first = result.current + + setSettings({ roadtrip_vehicle: 'combustion', roadtrip_range_km: 640 }) + + // The other half of case 009: memoised is not frozen. A stale object here would keep + // the old budget on screen until something else happened to re-render. + expect(result.current.rangeKm).toBe(640) + expect(result.current).not.toBe(first) + }) +}) 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/components/Roadtrip/vehicleRange.test.ts b/client/src/components/Roadtrip/vehicleRange.test.ts new file mode 100644 index 000000000..eaabec5e1 --- /dev/null +++ b/client/src/components/Roadtrip/vehicleRange.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest' +import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange' + +/** + * FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of. + * + * Two things are worth guarding here. The arithmetic itself, because it decides where + * every fuel warning lands, and the unit round trip, because an imperial traveller who + * types 15 into a field labelled gallons must not end up with 15 litres in storage. + */ + +describe('rangeFromSpec', () => { + it('FE-ROADTRIP-VEHRANGE-001: a tank divided by a consumption is a range', () => { + expect(rangeFromSpec('combustion', { tankLitres: 55, litresPer100: 7 })).toBe(786) + }) + + it('FE-ROADTRIP-VEHRANGE-002: a battery divided by a consumption is a range', () => { + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16 })).toBe(363) + }) + + it('FE-ROADTRIP-VEHRANGE-003: age comes off the battery before the division', () => { + // 58 kWh minus a tenth is 52.2, and 52.2 at 16 per hundred is 326.25 km. + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 10 })).toBe(326) + }) + + it('FE-ROADTRIP-VEHRANGE-004: half a pair is not a range', () => { + // Guessing the missing half would put the warnings at the wrong place while + // looking exact, which is worse than saying nothing. + expect(rangeFromSpec('combustion', { tankLitres: 55 })).toBeNull() + expect(rangeFromSpec('combustion', { litresPer100: 7 })).toBeNull() + expect(rangeFromSpec('electric', { batteryKwh: 58 })).toBeNull() + }) + + it('FE-ROADTRIP-VEHRANGE-005: figures belong to the kind of car they came from', () => { + // A tank size on an electric car is a leftover from before the picker changed, + // not a battery. + expect(rangeFromSpec('electric', { tankLitres: 55, litresPer100: 7 })).toBeNull() + expect(rangeFromSpec('combustion', { batteryKwh: 58, kwhPer100: 16 })).toBeNull() + expect(rangeFromSpec(null, { tankLitres: 55, litresPer100: 7 })).toBeNull() + }) + + it('FE-ROADTRIP-VEHRANGE-006: a battery reported as entirely gone is a typo, not a car', () => { + // Capped at 90 %, because zero usable capacity would put the dry point at the + // first metre of the trip and divide by nothing. + expect(rangeFromSpec('electric', { batteryKwh: 58, kwhPer100: 16, degradationPercent: 100 })).toBe(36) + }) + + it('FE-ROADTRIP-VEHRANGE-007: nonsense in a field is the same as an empty field', () => { + expect(rangeFromSpec('combustion', { tankLitres: 0, litresPer100: 7 })).toBeNull() + expect(rangeFromSpec('combustion', { tankLitres: -55, litresPer100: 7 })).toBeNull() + expect(rangeFromSpec('combustion', { tankLitres: Number.NaN, litresPer100: 7 })).toBeNull() + }) +}) + +describe('effectiveRangeKm', () => { + it('FE-ROADTRIP-VEHRANGE-008: the parts win over a typed round number', () => { + // Somebody who gave a tank size and a consumption said something more exact + // than "about 500", and the dialog shows them the result. + expect(effectiveRangeKm('combustion', { tankLitres: 55, litresPer100: 7 }, 500)).toBe(786) + }) + + it('FE-ROADTRIP-VEHRANGE-009: an incomplete pair hands the typed number straight back', () => { + expect(effectiveRangeKm('combustion', { tankLitres: 55 }, 500)).toBe(500) + expect(effectiveRangeKm(null, {}, 500)).toBe(500) + }) + + it('FE-ROADTRIP-VEHRANGE-010: no figures anywhere is no limit', () => { + expect(effectiveRangeKm(null, {}, 0)).toBeNull() + expect(effectiveRangeKm('electric', {}, undefined)).toBeNull() + }) +}) + +describe('unit round trip', () => { + it('FE-ROADTRIP-VEHRANGE-011: metric figures are stored exactly as typed', () => { + expect(storeSpec('tankLitres', 55, false)).toBe(55) + expect(showSpec('tankLitres', 55, false)).toBe(55) + expect(showSpec('litresPer100', 7.4, false)).toBe(7.4) + }) + + it('FE-ROADTRIP-VEHRANGE-012: gallons go in and come back out as the same gallons', () => { + // 15 US gallons is 56.78 litres, and storage is litres — the same rule the + // range itself follows, where storage is always kilometres. + const stored = storeSpec('tankLitres', 15, true) + expect(stored).toBe(56.78) + expect(showSpec('tankLitres', stored, true)).toBe(15) + }) + + it('FE-ROADTRIP-VEHRANGE-013: miles per gallon is the inverse, and survives the trip', () => { + // The one field that is not a scale factor: 30 mpg is 7.84 L/100 km, and a + // straight multiplication would have stored a car four times as thirsty. + const stored = storeSpec('litresPer100', 30, true) + expect(stored).toBe(7.84) + expect(showSpec('litresPer100', stored, true)).toBe(30) + expect(specUnit('litresPer100', true)).toBe('mpg') + expect(specUnit('litresPer100', false)).toBe('L/100 km') + }) + + it('FE-ROADTRIP-VEHRANGE-014: electric consumption is quoted per 100 miles', () => { + // What the EPA prints on the window sticker, so the figure is one a US + // traveller can copy rather than convert. + expect(showSpec('kwhPer100', 16, true)).toBe(25.7) + expect(specUnit('kwhPer100', true)).toBe('kWh/100 mi') + // A capacity is a capacity everywhere. + expect(specUnit('batteryKwh', true)).toBe('kWh') + 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() + expect(storeSpec('tankLitres', 0, true)).toBe(0) + }) +}) diff --git a/client/src/components/Roadtrip/vehicleRange.ts b/client/src/components/Roadtrip/vehicleRange.ts new file mode 100644 index 000000000..cda588664 --- /dev/null +++ b/client/src/components/Roadtrip/vehicleRange.ts @@ -0,0 +1,170 @@ +import type { VehicleKind } from './roadtripModel' + +/** + * How far the vehicle goes on one fill, worked out from what it is made of. + * + * Two ways to the same number, and the point of having both is that people know their + * car in different ways. Some know "about 600 km"; some know the tank holds 55 litres + * and it does 7 per hundred, and would have to do the division themselves. Whoever types + * a range directly still gets exactly what they typed — this only fills that number in + * when it can and the field is empty. + * + * Deliberately not a consumption model. ABRP asks for reference speed, relative speed and + * a drive profile because it predicts a specific drive; this answers "how far on one + * fill", which is a division. Adding speed terms would make the number look precise + * without making it right, since the biggest factor by far is how the person drives. + * + * Pure: no React, no store, no network. + */ + +export interface VehicleSpec { + /** Litres the tank holds. Petrol only. */ + tankLitres?: number | null + /** Litres per 100 km. Petrol only. */ + litresPer100?: number | null + /** Usable battery when new, in kWh. Electric only. */ + batteryKwh?: number | null + /** kWh per 100 km. Electric only. */ + kwhPer100?: number | null + /** + * How much of the battery the years have taken, as a percentage. + * + * A five-year-old car does not carry the capacity on its data sheet, and the gap is + * the difference between arriving and not on the last leg of a day. + */ + degradationPercent?: number | null +} + +/** A positive, finite number, or null. Everything here comes from a text field. */ +function positive(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +/** + * The range in kilometres that the parts add up to, or null when they do not add up. + * + * 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 atLeastAKilometre(Math.round((tank / per100) * 100)) + } + + if (vehicle === 'electric') { + const battery = positive(spec.batteryKwh) + const per100 = positive(spec.kwhPer100) + if (!battery || !per100) return null + // Degradation only ever takes away, and never all of it: a battery reported as 100 % + // gone is a typo, not a car, and dividing by zero range would put the dry point at + // 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 atLeastAKilometre(Math.round((usable / per100) * 100)) + } + + return null +} + +/** + * The range to plan with: what the parts say, or what was typed, in that order. + * + * The computed one wins because it is the more specific answer — somebody who filled in + * a tank size and a consumption said something more exact than a round number, and the + * dialog shows them the result so it is never a surprise. + */ +export function effectiveRangeKm( + vehicle: VehicleKind | null | undefined, + spec: VehicleSpec, + typedKm: number | null | undefined, +): number | null { + return rangeFromSpec(vehicle, spec) ?? positive(typedKm) +} + +/** + * The unit each field is typed in, per measurement system. + * + * Storage is always metric, exactly as the range itself is always kilometres: an imperial + * traveller who typed 15 into a field labelled "gallons" must not have 15 litres stored, + * or every warning after it fires at a quarter of the right distance. + * + * The imperial pair is not a straight translation of the metric one. Fuel economy is + * quoted as miles per gallon, which is the INVERSE of litres per hundred, while electric + * economy is quoted by the EPA as kWh per 100 miles, which is not. Forcing one shape onto + * both would leave one of the two reading as a figure nobody recognises. + */ +export type SpecKey = keyof VehicleSpec + +const LITRES_PER_GALLON = 3.785411784 +/** litres/100 km × mpg = this. The one constant that makes the inversion exact. */ +const MPG_PRODUCT = 235.214583 +const KM_PER_MILE = 1.609344 + +export function specUnit(key: SpecKey, imperial: boolean): string { + switch (key) { + case 'tankLitres': return imperial ? 'gal' : 'L' + case 'litresPer100': return imperial ? 'mpg' : 'L/100 km' + case 'batteryKwh': return 'kWh' + case 'kwhPer100': return imperial ? 'kWh/100 mi' : 'kWh/100 km' + default: return '%' + } +} + +/** How many decimals the field is worth typing. A tank is not measured to a tenth. */ +function places(key: SpecKey, imperial: boolean): number { + if (key === 'degradationPercent') return 0 + if (key === 'tankLitres') return imperial ? 1 : 0 + if (key === 'litresPer100') return imperial ? 0 : 1 + return 1 +} + +function convert(key: SpecKey, value: number, imperial: boolean, toStorage: boolean): number { + if (!imperial) return value + switch (key) { + case 'tankLitres': return toStorage ? value * LITRES_PER_GALLON : value / LITRES_PER_GALLON + // Inverse in both directions, which is why one expression covers the pair. + case 'litresPer100': return MPG_PRODUCT / value + case 'kwhPer100': return toStorage ? value / KM_PER_MILE : value * KM_PER_MILE + default: return value + } +} + +function round(value: number, decimals: number): number { + const f = 10 ** decimals + return Math.round(value * f) / f +} + +/** The stored figure as it should appear in the field, or undefined when there is none. */ +export function showSpec(key: SpecKey, stored: number | null | undefined, imperial: boolean): number | undefined { + const value = positive(stored) + if (value === null) return undefined + return round(convert(key, value, imperial, false), places(key, imperial)) +} + +/** + * A typed figure as it should be stored. Zero comes back as zero, meaning "not said". + * + * Two decimals rather than none: a gallon is 3.79 litres, so an imperial figure that + * round-tripped through a whole number would drift by up to half a litre and show the + * traveller a different number than the one they typed. + */ +export function storeSpec(key: SpecKey, shown: number, imperial: boolean): number { + if (!(shown > 0)) return 0 + return round(convert(key, shown, imperial, true), 2) +} diff --git a/client/src/components/Studio/BookSheetsView.tsx b/client/src/components/Studio/BookSheetsView.tsx index ec0584230..3c872d331 100644 --- a/client/src/components/Studio/BookSheetsView.tsx +++ b/client/src/components/Studio/BookSheetsView.tsx @@ -1,6 +1,6 @@ import type { BookDocument } from '@trek/shared' import { SpreadView } from './SpreadView' -import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets' +import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode, foliosOf } from './bookSheets' /** * The book laid out for a press. @@ -107,7 +107,7 @@ function SheetView({ diff --git a/client/src/components/Studio/PageNumbers.tsx b/client/src/components/Studio/PageNumbers.tsx index 7307b14b8..abab41bed 100644 --- a/client/src/components/Studio/PageNumbers.tsx +++ b/client/src/components/Studio/PageNumbers.tsx @@ -1,6 +1,7 @@ import type { BookPageSetup, BookSpread } from '@trek/shared' import { fontStack } from './bookFonts' import { folioInk } from './folioColour' +import { isNumbered } from './bookSheets' /** * The folios. @@ -17,33 +18,46 @@ import { folioInk } from './folioColour' * not the ones that get printed. * * The cover and the back cover carry none. A folio on a cover is a mistake in - * every book ever bound. + * every book ever bound. The single first and last pages carry one each, on + * the side of the book they are bound on (#2317). */ export function PageNumbers({ - spread, page, spreadIndex, + spread, page, folios, }: { spread: BookSpread page: BookPageSetup - /** Position in the document, cover included. */ - spreadIndex: number + /** + * The numbers this spread carries, from foliosOf: two for a spread, one for + * a first or last page, none for a cover. Counted by the caller, which has + * the whole book — a spread on its own does not know how many pages sit + * before it. + */ + folios: readonly number[] }) { const cfg = page.pageNumbers - if (!cfg?.show || spread.role !== 'inner') return null + if (!cfg?.show || !isNumbered(spread.role) || folios.length === 0) return null /* - * The cover is one sheet with a front and a back, so the first inner spread - * opens on `startAt` — 2 by default, the page you see when you open a book - * whose cover is page 1. `spreadIndex` counts the cover, hence the -1. + * Which page of the sheet each number sits on. A spread numbers both; the + * first page is a right-hand leaf and the last a left-hand one, and each + * carries its one number on the side it actually is. */ - const left = cfg.startAt + (spreadIndex - 1) * 2 - const right = left + 1 + const sides: Array<['left' | 'right', number]> = spread.role === 'inner' + ? [['left', folios[0]], ['right', folios[1]]] + : [[spread.role === 'first' ? 'right' : 'left', folios[0]]] const size = cfg.size const y = page.pageHeight - cfg.margin - /** Where the number sits on each page, given which edge it hangs from. */ + /** + * Where the number sits on each page, given which edge it hangs from. + * + * A single page is one page wide, so its number sits at x 0 whichever + * side of the book it is bound on; the side only decides which edge is + * the outer one. + */ const place = (side: 'left' | 'right') => { - const pageX = side === 'left' ? 0 : page.pageWidth + const pageX = side === 'left' || spread.role !== 'inner' ? 0 : page.pageWidth if (cfg.position === 'centre') { return { x: pageX, w: page.pageWidth, align: 'center' as const } } @@ -58,7 +72,7 @@ export function PageNumbers({ return ( <> - {([['left', left], ['right', right]] as const).map(([side, number]) => { + {sides.map(([side, number]) => { const at = place(side) /* * Sampled at the middle of the number's own box, which is where the diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx index 25f1c1daa..4743f99c6 100644 --- a/client/src/components/Studio/SpreadView.tsx +++ b/client/src/components/Studio/SpreadView.tsx @@ -427,7 +427,7 @@ export function ElementView({ export function SpreadView({ spread, page, - spreadIndex = 0, + folios = [], big = false, showGuides = false, print = false, @@ -436,11 +436,12 @@ export function SpreadView({ spread: BookSpread page: BookPageSetup /** - * Where this spread sits in the book. Only the folios need it — a page - * number is the one thing on the page that is a function of position rather - * than of the document. + * The page numbers this spread carries, from foliosOf. Only PageNumbers + * needs them — a page number is the one thing on the page that is a + * function of position in the book rather than of the spread itself, and + * the caller is the one holding the book. */ - spreadIndex?: number + folios?: readonly number[] big?: boolean showGuides?: boolean /** The print renderer passes this: no guides, no placeholders, no chrome. */ @@ -470,7 +471,7 @@ export function SpreadView({ ))} - + {showGuides && ( <> diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 6768cb70f..150e6f1b2 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -59,6 +59,7 @@ const HANDLE_POS: Record select([])} > - 0.34} showGuides dropLabel={dropLabel} /> + 0.34} showGuides dropLabel={dropLabel} /> {cursors && cursors.length > 0 && ( diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx index dd25d9f65..571116761 100644 --- a/client/src/components/Studio/StudioShell.tsx +++ b/client/src/components/Studio/StudioShell.tsx @@ -7,6 +7,7 @@ import { } from 'lucide-react' import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio' import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets' +import { foliosOf } from './bookSheets' import { StudioSidebar } from './StudioSidebar' import { StudioCanvas } from './StudioCanvas' import { StudioInspector } from './StudioInspector' @@ -427,6 +428,7 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) { - +
{bookView && !single && }
{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/index.css b/client/src/index.css index a0ec5300e..77178ad1b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -523,6 +523,44 @@ 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; } + +/* Roadtrip: der Block, den eine Nachtfahrt auf dem Folgetag hinterlaesst. Er faellt + von oben in die Karte, weil er von der Karte darueber kommt — kurz genug, dass ein + Neuberechnen der Ankunftszeiten nicht als Zappeln liest. */ +@keyframes trek-spill-in { + from { opacity: 0; transform: translateY(-6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.trek-spill-in { + animation: trek-spill-in 320ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* Roadtrip: die Reserveleuchte am Band, an dem der Tank leerlaeuft. Das Gegenstueck + zu den Zzz am Tageswechsel — dort schlaeft der Tag ein, hier geht die Lampe im + Armaturenbrett an. Langsam an, kurz aus: eine Warnleuchte blinkt nicht hektisch, + sie glimmt. */ +@keyframes trek-lowfuel { + 0%, 62%, 100% { opacity: 1; } + 74% { opacity: 0.28; } + 86% { opacity: 1; } +} +.trek-lowfuel { + animation: trek-lowfuel 2.6s ease-in-out infinite; +} + /* Stagger-Helpers für Listen — Enter-Animation mit Offset */ @keyframes trek-fade-up { from { opacity: 0; transform: translateY(6px); } @@ -544,13 +582,24 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti /* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */ @media (prefers-reduced-motion: reduce) { .trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter, - .trek-modal-enter, .trek-toast-enter, .trek-stagger > * { + .trek-modal-enter, .trek-toast-enter, .trek-stagger > *, .trek-spill-in { animation: trek-backdrop-enter 120ms ease-out; } + /* Die Reserveleuchte steht still statt zu glimmen — sie bleibt an, was die Aussage + ohnehin ist. */ + .trek-lowfuel { + animation: none; + } .trek-skeleton { 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 { diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index 4c3d21321..b0db824ba 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -261,6 +261,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) 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 */}
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/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 && (
)}
+
(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 + // 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. @@ -397,10 +406,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
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} @@ -443,7 +451,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined} roadtripVias={roadtripActive ? roadtripVias.byDay : undefined} alternativeRoutes={alternativeOverlays} - focusPoints={overviewActive ? tripOverview.focusPoints : alternativeFocusPoints} + focusPoints={overviewActive ? tripOverview.focusPoints : mapFocusPoints} clusterLoosely={roadtripActive} activeAlternative={highlightedAlternative} onChooseAlternative={chooseRouteAlternative} @@ -568,9 +576,15 @@ 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} + refuel={refuel} + onAskRefuel={askRefuel} + onAcceptRefuel={can('day_edit', trip) ? acceptRefuel : undefined} + collapsedDayIds={collapsedRoadtripDays} + onToggleDay={toggleRoadtripDay} /> ) : ( @@ -707,6 +721,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) }} @@ -874,7 +889,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/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 0c81ac81e..02ea08be2 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/sharedTrip/SharedBookingDetails.tsx b/client/src/pages/sharedTrip/SharedBookingDetails.tsx new file mode 100644 index 000000000..5c80d0e1e --- /dev/null +++ b/client/src/pages/sharedTrip/SharedBookingDetails.tsx @@ -0,0 +1,44 @@ +import { ExternalLink, FileText } from 'lucide-react' +import { isHttpUrl, linkHost } from './sharedTripModel' + +/** + * The note and the link on a shared booking (#2320). + * + * A booking on a public link used to be its title, its time and its status. + * The note the owner attached ("meet at the north entrance") and the booking + * page they linked are what a fellow traveller opens the link for, so both + * show now. What does not show is anything that would let a stranger touch + * the booking: the server keeps the confirmation number and the ticket data + * back, and this block only knows the two fields it is handed. + * + * The link opens in a new tab with no opener, and only when it is http(s) — + * checked here as well as on the server, so the page never trusts a payload + * to have done it. + */ +export function SharedBookingDetails({ notes, url }: { notes?: string | null; url?: string | null }) { + const note = notes?.trim() || null + const link = isHttpUrl(url) ? url.trim() : null + if (!note && !link) return null + return ( +
+ {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/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index e9df4a853..2674d09d2 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1,4 +1,4 @@ -// FE-TP-ROAD-001 to FE-TP-ROAD-056 +// FE-TP-ROAD-001 to FE-TP-ROAD-074 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -15,6 +15,7 @@ import { addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi, } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' +import { dayColor } from '../../components/Roadtrip/dayColors' /** * The road trip half of the planner hook. @@ -89,6 +90,7 @@ const rt = vi.hoisted(() => { days: [] as Array>, quietDays: [] as unknown[], lines: [] as unknown[], + lineDays: [] as number[], accessLines: [] as unknown[], vias: [] as unknown[], segments: [] as unknown[], @@ -271,6 +273,8 @@ beforeEach(() => { rt.vias.stale = false rt.vias.editable = true rt.routes.days = [] + rt.routes.lines = [] + rt.routes.lineDays = [] rt.corridor.day = undefined rt.corridor.visible = [] rt.corridor.widthKm = 5 @@ -1060,13 +1064,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() @@ -1137,3 +1193,346 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => { expect(result.current.stopDraft).toBeNull() }) }) + +/** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */ +const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({ + placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex, +}) + +/** Four drawn legs: two on the first day, one each on the two after it. */ +const LINES: [number, number][][] = [ + [[53.55, 9.99], [53.2, 10.7]], + [[53.2, 10.7], [52.52, 13.4]], + [[52.52, 13.4], [50.1, 12.0]], + [[50.1, 12.0], [48.13, 11.58]], +] + +/** Per-day colours on or off, without dropping the defaults the providers read. */ +function setDayColors(on: boolean) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, roadtrip_day_colors: on } }) +} + +describe('useTripPlanner road trip: folding a day off the map', () => { + /** + * Three cards, and two of the places drawn on two cards each: the stop a day ends on is + * the stop the next one sets off from. That overlap is what the second pass below is + * about, and a fixture without it would let a one-pass answer through. + */ + const threeCards = () => { + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 }), buildDay({ id: 7, day_number: 3 })], + places: [ + buildPlace({ id: 1101, lat: 53.55, lng: 9.99 }), + buildPlace({ id: 1102, lat: 52.52, lng: 13.4 }), + buildPlace({ id: 1103, lat: 50.1, lng: 12.0 }), + buildPlace({ id: 1104, lat: 48.13, lng: 11.58 }), + ], + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [ + { dayId: 5, dayNumber: 1, stops: [drawn(1101, 53.55, 9.99, 5, 0), drawn(1102, 52.52, 13.4, 5, 1)], geometry: LINES[0] }, + { dayId: 6, dayNumber: 2, stops: [drawn(1102, 52.52, 13.4, 6, 0), drawn(1103, 50.1, 12.0, 6, 1)], geometry: LINES[2] }, + { dayId: 7, dayNumber: 3, stops: [drawn(1103, 50.1, 12.0, 7, 0), drawn(1104, 48.13, 11.58, 7, 1)], geometry: LINES[3] }, + ] + rt.routes.lines = LINES + rt.routes.lineDays = [1, 1, 2, 3] + } + + it('FE-TP-ROAD-060: folding a day folds that one alone, and folding it again brings it back', async () => { + // One set for the whole rail, so a header that wrote its own answer over it instead + // of editing a copy would fold one card and unfold every other in the same click. + threeCards() + const { result } = await renderRoadtrip() + + act(() => { result.current.toggleRoadtripDay(6) }) + act(() => { result.current.toggleRoadtripDay(7) }) + expect([...result.current.collapsedRoadtripDays]).toEqual([6, 7]) + + act(() => { result.current.toggleRoadtripDay(6) }) + expect([...result.current.collapsedRoadtripDays]).toEqual([7]) + }) + + it('FE-TP-ROAD-061: a folded card takes ITS lines off the map and leaves the rest drawn', async () => { + // The fold is keyed by day id and the lines are labelled by day number. They are + // different numbers on this trip, as on any trip whose days were not created in + // order, so a filter built from the wrong one quietly hides nothing at all. + threeCards() + const { result } = await renderRoadtrip() + expect(result.current.roadtripMapLines).toHaveLength(4) + + act(() => { result.current.toggleRoadtripDay(6) }) + + expect(result.current.roadtripMapLines).toEqual([LINES[0], LINES[1], LINES[3]]) + }) + + it('FE-TP-ROAD-062: the colours drop with the lines, so what is left still lines up', async () => { + // The map reads the two lists side by side. Leaving a folded day colour in shifts + // every colour after it onto the wrong road, which reads as the days having moved. + threeCards() + const { result } = await renderRoadtrip() + // Off is an absent list rather than a list of blues: a trip that never turns this on + // is handed nothing and the map paints what it always painted. + expect(result.current.roadtripLineColors).toBeUndefined() + + act(() => { setDayColors(true) }) + act(() => { result.current.toggleRoadtripDay(6) }) + + expect(result.current.roadtripLineColors).toEqual([dayColor(1), dayColor(1), dayColor(3)]) + expect(result.current.roadtripLineColors).toHaveLength(result.current.roadtripMapLines.length) + }) + + it('FE-TP-ROAD-063: a folded card takes its own stops off the map with it', async () => { + threeCards() + const { result } = await renderRoadtrip() + + act(() => { result.current.toggleRoadtripDay(5) }) + + expect(result.current.roadtripMapPlaces.map(p => p.id)).not.toContain(1101) + // The fold and nothing else: the planner own map still holds it, so a filter that + // dropped the place for some unrelated reason would show up right here. + expect(result.current.mapPlaces.map(p => p.id)).toContain(1101) + }) + + it('FE-TP-ROAD-064: a stop another card still draws stays, and goes only when that card folds too', async () => { + // The second pass is the whole rule. The place a day ends on is the place the next + // day sets off from, so folding one card must not rub it off the other. + threeCards() + const { result } = await renderRoadtrip() + + act(() => { result.current.toggleRoadtripDay(5) }) + expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1102, 1103, 1104]) + + act(() => { result.current.toggleRoadtripDay(6) }) + expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1103, 1104]) + }) +}) + +describe('useTripPlanner road trip: how full one stop fills up', () => { + it('FE-TP-ROAD-065: the fill is one field on one place, and handing it back sends null rather than nothing', async () => { + // null is not "leave it alone", it is "follow my own setting again" — the only way + // back from a figure typed on a single stop. Left out of the payload the update folds + // it into "unchanged" and the stop keeps the number for good. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })], assignments: { '5': [stopAt(11, 5, 0)] } }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStopFill(1011, 80) }) + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { fill_percent: 80 }) + + actions.updatePlace.mockRejectedValue(new Error('read only')) + await act(async () => { await result.current.setRoadtripStopFill(1011, null) }) + expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 1011, { fill_percent: null }) + expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true) + }) +}) + +describe('useTripPlanner road trip: a card that opens with yesterday stop', () => { + /** + * One card, day 6, whose drive begins with a stop stored on day 5: the night drive + * arrived after midnight, so the rail draws that stop here while the server still files + * it under the day it set off from. + */ + const spilledCard = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 6, dayNumber: 2 } + rt.routes.days = [{ + dayId: 6, + dayNumber: 2, + stops: [drawn(1102, 53.0, 11.5, 5, 1), drawn(1103, 52.52, 13.4, 6, 0)], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.8, 12.4], [52.52, 13.4]], + }] + } + + it('FE-TP-ROAD-066: a via is filed under the day its anchor stop is STORED on, not the card it is drawn on', async () => { + // Written with the card own numbers the via matches no stop the next time the day is + // routed: the road springs back to where it was and the gesture reads as having done + // nothing at all. + spilledCard() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(52.8, 12.4) }) + + expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 52.8, 12.4) + }) + + it('FE-TP-ROAD-072: a via dropped on the incoming night drive follows the stop it left from', async () => { + // The stretch between the card first drawn point and its first stop is last night + // driving: it is drawn here, but it leaves from a stop on the card before this one. + // Anchored to this card first stop instead, the via would be filed on the leg AFTER + // that stop, and the route would run forward, double back to the point and carry on. + // + // The rule used to be asked of the insert index, which cannot answer it: + // insertIndexForAlong clamps to at least 1 for any card with two stops or more, and + // the rail publishes no other kind. The guard read correctly and never ran. + spilledCard() + rt.routes.days[0].spills = [{ + at: 0, + count: 1, + fromDayNumber: 1, + // The stop the night drive left from: FIRST on day 5, while the first stop drawn + // on this card is the second one, reached after midnight. Different numbers on + // purpose, or the two paths through the lookup would answer alike and this case + // would pass whichever one ran. + fromStop: drawn(1101, 53.55, 9.99, 5, 0), + }] + const { result } = await renderRoadtrip() + + // Half way along the night stretch, well before the first stop drawn on this card. + await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) }) + + expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.3, 10.7) + }) + + it('FE-TP-ROAD-073: a point before the first stop of an ordinary card still takes that stop', async () => { + // The other half of the same rule, and the reason it is asked of the spill rather + // than of the distance alone: a card that received no night drive has nothing before + // its first stop but its own first leg, so a point there belongs on that leg. + spilledCard() + rt.routes.days[0].spills = [] + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) }) + + expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 53.3, 10.7) + }) + + it('FE-TP-ROAD-067: a drag keeps to the day the via is stored on, wherever that day stops are drawn', async () => { + // Not "the card with that id". After a night drive the stops of day 5 are drawn on + // card 6, and measuring the drag against card 5 alone leaves it with no anchor to + // offer — which is exactly the anchor it needed. + spilledCard() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripVia(5, 9, 52.8, 12.4) }) + expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 52.8, 12.4, 1) + + // The same rule the other way round: the stop nearest that point belongs to day 5, so + // a via of day 6 takes no anchor from it and the pin it already has stands. + await act(async () => { await result.current.moveRoadtripVia(6, 9, 52.8, 12.4) }) + expect(rt.vias.move).toHaveBeenLastCalledWith(6, 9, 52.8, 12.4, undefined) + }) +}) + +describe('useTripPlanner road trip: somewhere to fill up', () => { + /** + * A day driven straight down the tenth meridian, so a kilometre along the road is a + * kilometre anybody can check: a degree of latitude is 111 km and nothing here bends. + * Its first two stops are stored on the day before, the night drive having arrived + * after midnight, which is what the accepting case turns on. + */ + const drivenCard = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 6, dayNumber: 2 } + rt.routes.days = [{ + dayId: 6, + dayNumber: 2, + stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)], + geometry: [[53, 10], [52, 10], [51, 10]], + drivingGeometry: [[53, 10], [52, 10], [51, 10]], + }] + } + + /** Where the tank runs out: 190 km into a 222 km day, on the second leg. */ + const dry = { legIndex: 1, intoLegKm: 79, drivenMeters: 190_000, sinceKm: 500, lat: 51.29, lng: 10 } + + const pump = (name: string, lat: number) => ({ + osm_id: `node/${name}`, name, lat, lng: 10, category: 'fuel', poi_type: 'fuel', + address: null, website: null, phone: null, opening_hours: null, cuisine: null, + }) + + it('FE-TP-ROAD-068: the day own stops go with the question, so a pump already on the plan is not offered again', async () => { + drivenCard() + const pois = vi.spyOn(mapsApi, 'pois').mockResolvedValue({ + pois: [pump('Rasthof Dammer Berge', 52.5), pump('Schon geplant', 52)], + source: 'openstreetmap', + truncated: false, + } as never) + const { result } = await renderRoadtrip() + + // A day the rail draws no drive for has no line to measure along, and asking anyway + // spends a request on a question that cannot be answered. + act(() => { result.current.askRefuel(999, dry) }) + expect(pois).not.toHaveBeenCalled() + + await act(async () => { result.current.askRefuel(6, dry) }) + await waitFor(() => expect(result.current.refuel.outcome).toBe('found')) + + expect(pois).toHaveBeenCalledTimes(1) + // The second one stands on the day own middle stop, and both are within range of the + // dry point — so only the day stops travelling with the question keep it out. + expect(result.current.refuel.results.map(r => r.name)).toEqual(['Rasthof Dammer Berge']) + // Which dry point is being answered, so a second warning cannot read the first list. + expect(result.current.refuel.openFor).toBe('6:1') + }) + + it('FE-TP-ROAD-069: while offers are on the table the map frames THEM, not the alternatives under them', async () => { + // Somebody is being asked to accept a stop, and a stop off the edge of the map cannot + // be judged. Averaging the two frames would have shown neither properly. + drivenCard() + rt.alt.open = { + dayId: 6, index: 0, loading: false, error: false, + // Two of them, because one road is not a choice and the picker draws nothing for it. + routes: [ + { coordinates: [[53, 10], [51, 10]], distance: 222_000, duration: 8_400, divergence: { lat: 52, lng: 10 } }, + { coordinates: [[53, 10], [52.4, 10.6], [51, 10]], distance: 240_000, duration: 9_000, divergence: { lat: 52.4, lng: 10.6 } }, + ], + } + vi.spyOn(mapsApi, 'pois').mockResolvedValue({ + pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false, + } as never) + const { result } = await renderRoadtrip() + expect(result.current.mapFocusPoints).toEqual(result.current.alternativeFocusPoints) + expect(result.current.mapFocusPoints.length).toBeGreaterThan(0) + + await act(async () => { result.current.askRefuel(6, dry) }) + await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1)) + + expect(result.current.mapFocusPoints).toEqual([[52.5, 10]]) + }) + + it('FE-TP-ROAD-070: accepting one closes the offers and opens the popup at the stop OWN day and position', async () => { + // The index the offer was measured at counts along the card, and this card begins + // with two stops stored on the day before. Written with the card numbers the new stop + // lands in the wrong day list, at a position that means something else there. + drivenCard() + vi.spyOn(mapsApi, 'pois').mockResolvedValue({ + pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false, + } as never) + const { result } = await renderRoadtrip() + + await act(async () => { result.current.askRefuel(6, dry) }) + await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1)) + + // Dry on the first leg, so the stop goes in front of the one it was measured against: + // the card second, which day 5 holds at position 2. + act(() => { result.current.acceptRefuel(6, result.current.refuel.offered[0], { ...dry, legIndex: 0 }) }) + + expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 2 }) + expect(result.current.refuel.openFor).toBeNull() + }) + + it('FE-TP-ROAD-074: a reader changing a stop from the rail writes nothing at all', async () => { + // The two one-field writes the rail offers used to go straight to the API while their + // neighbour setRoadtripStay checked first. The server refused them, so nothing was + // ever saved, but the reader got an error toast for touching a control that should + // not have acted — and the rail redrew off a store the write never reached. + asReader('place_edit') + drivenCard() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStopKind(1102, 'fuel') }) + await act(async () => { await result.current.setRoadtripStopFill(1102, 80) }) + + expect(actions.updatePlace).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-071: a reader is not handed the stop to accept', async () => { + asReader('day_edit') + drivenCard() + const { result } = await renderRoadtrip() + + act(() => { result.current.acceptRefuel(6, pump('Rasthof Dammer Berge', 52.5) as never, dry) }) + + expect(result.current.stopDraft).toBeNull() + }) +}) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 301cdfc0a..13d74eb37 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' +import { dayColor } from '../../components/Roadtrip/dayColors' import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' @@ -25,6 +26,8 @@ import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' 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' @@ -35,8 +38,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' @@ -555,6 +557,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, @@ -788,7 +791,82 @@ 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) => { + /** + * A colour per drawn line, or none at all. + * + * Off is not "all the same colour" but an absent array: the map then paints the blue it + * has always painted, so a trip that never turns this on renders byte for byte the way + * it did. + */ + /** + * Days folded down to their header in the road trip rail. + * + * Its own state rather than the day plan's `expandedDayIds`: that one is owned by + * `DayPlanSidebar`, which republishes it from its own state every time it mounts — so a + * day folded in the rail would spring back open the moment the other view was visited. + */ + const [collapsedRoadtripDays, setCollapsedRoadtripDays] = useState>(new Set()) + const toggleRoadtripDay = useCallback((dayId: number) => { + setCollapsedRoadtripDays(prev => { + const next = new Set(prev) + if (!next.delete(dayId)) next.add(dayId) + return next + }) + }, []) + + /** + * The road trip's lines, minus the days that are folded away. + * + * Folding a card takes that day off the map, which is most of what folding is for: a + * rail entry can be scrolled past, a line across the map cannot be looked away from. + * `lineDays` runs parallel to `lines`, so both are filtered in one pass and the colours + * stay lined up with what is left. + */ + const roadtripMapLines = useMemo(() => { + if (!collapsedRoadtripDays.size) return roadtripRoutes.lines + const hidden = new Set( + roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), + ) + return roadtripRoutes.lines.filter((_, i) => !hidden.has(roadtripRoutes.lineDays[i])) + }, [roadtripRoutes.lines, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays]) + + /** + * The map's places with the folded cards' stops taken out. + * + * A second pass rather than a branch inside `mapPlaces`: that memo runs long before the + * road trip is routed, and the answer here needs the CHAINS — a card is a date, and + * after a night drive it holds stops stored on the day before (`nightSpill.ts`), so + * what a folded card hides is what is drawn on it, not what is filed under its day. + * + * A place still drawn on some other card stays, which is the rule the day plan's own + * declutter follows. + */ + const roadtripMapPlaces = useMemo(() => { + if (!collapsedRoadtripDays.size) return mapPlaces + const hidden = new Set() + for (const day of roadtripRoutes.days) { + if (!collapsedRoadtripDays.has(day.dayId)) continue + for (const stop of day.stops) hidden.add(stop.placeId) + } + for (const day of roadtripRoutes.days) { + if (collapsedRoadtripDays.has(day.dayId)) continue + for (const stop of day.stops) hidden.delete(stop.placeId) + } + return mapPlaces.filter(p => !hidden.has(p.id)) + }, [mapPlaces, roadtripRoutes.days, collapsedRoadtripDays]) + + const roadtripLineColors = useMemo( + () => { + if (!settings.roadtrip_day_colors) return undefined + const hidden = new Set( + roadtripRoutes.days.filter(d => collapsedRoadtripDays.has(d.dayId)).map(d => d.dayNumber), + ) + return roadtripRoutes.lineDays.filter(n => !hidden.has(n)).map(n => dayColor(n)) + }, + [settings.roadtrip_day_colors, roadtripRoutes.lineDays, roadtripRoutes.days, collapsedRoadtripDays], + ) + + const saveRoadtripLimit = useCallback(async (key: string, value: number | string | boolean) => { try { await updateSettings({ [key]: value } as Partial) } catch { @@ -895,12 +973,30 @@ export function useTripPlanner() { * field on one place, and the rail redraws itself off the store the moment it lands. */ const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => { + if (!can('place_edit', trip)) return try { await tripActions.updatePlace(tripId, placeId, { stop_type: kind }) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [tripId, tripActions, toast, t]) + }, [tripId, tripActions, toast, t, can, trip]) + + /** + * 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) => { + if (!can('place_edit', trip)) return + 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, can, trip]) /** * Moves a stop within its day, from the road trip rail. @@ -1070,6 +1166,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], ) @@ -1088,6 +1185,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) @@ -1148,7 +1260,7 @@ export function useTripPlanner() { const stop = day?.stops[open.index] if (!day || !stop) return try { - await roadtripVias.add(open.dayId, day.stops.indexOf(stop), alt.divergence.lat, alt.divergence.lng) + await roadtripVias.add(stop.ownerDayId ?? day.dayId, stop.ownerIndex ?? open.index, alt.divergence.lat, alt.divergence.lng) routeAlternatives.close() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) @@ -1163,10 +1275,28 @@ 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) { + // NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored + // on, but that day's stops are no longer all on the card of the same name: after a + // night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card + // measured the new position against a line that no longer covers those stops — a + // point dragged near Brandenburg was projected onto the short remainder of card 1 + // and came back anchored to its last stop, which put the via on the night drive + // itself and pushed the stop before it over midnight. + // + // So every card is measured, and the answer is filtered by the day the ANCHOR is + // stored on. Same promise, kept against the stops rather than against the card. if (day.geometry.length < 2) continue const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) const hit = projectOntoRoute({ lat, lng }, spine) @@ -1174,9 +1304,46 @@ export function useTripPlanner() { if (best && hit.offRouteKm >= best.offRouteKm) continue // Which stop the via follows: the last one the car passes before reaching it. const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) - const afterIndex = Math.max(0, insertIndexForAlong(stopsAlong, hit.alongKm) - 1) - best = { dayId: day.dayId, afterIndex, offRouteKm: hit.offRouteKm } + // Before the card's first stop means the incoming night drive, which is drawn here + // but leaves from a stop on the card BEFORE this one (`nightSpill.ts`). Anchoring + // it to this card's first stop would file the via on the leg AFTER that stop, and + // the route would run forward, double back to the point, and carry on. + // + // Asked of the distance rather than of the index, because the index cannot answer + // it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and + // the rail only ever publishes cards with two stops or more. Written against the + // index this read as a guard and behaved as dead code, so a via dropped on the + // night stretch went to the first drawn stop after all, which is the exact failure + // the paragraph above describes. + const spilledIn = day.spills?.find(sp => sp.at === 0)?.fromStop + if (spilledIn && hit.alongKm < (stopsAlong[0] ?? 0)) { + const owner = spilledIn.ownerDayId ?? day.dayId + if (onlyDayId !== undefined && owner !== onlyDayId) continue + best = { dayId: owner, afterIndex: spilledIn.ownerIndex ?? 0, offRouteKm: hit.offRouteKm } + continue + } + const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 + // Named by the day the anchor stop is STORED on and its position there, not by the + // card and the position within it. A card is a date and can hold stops from the day + // before (`nightSpill.ts`), so those two numbers differ on any day that received a + // night drive — and a via filed under the card's numbers matches no stop when the + // route is next built, which reads as a drag that did nothing at all. + const anchor = day.stops[at] + if (!anchor) continue + // Falling back to the card's own numbers is not a guard against a bug, it is the + // meaning: a stop that names no other day IS stored on the card it is drawn on, + // which is every stop on a trip that never drives past midnight. + const owner = anchor.ownerDayId ?? day.dayId + // A drag stays on its own day; a fresh click may land wherever it landed. + if (onlyDayId !== undefined && owner !== onlyDayId) continue + best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, 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 { @@ -1184,17 +1351,75 @@ 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]) + + /** + * 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 at = Math.min(dry.legIndex + 1, day.stops.length - 1) + // That index counts along the CARD, and after a night drive a card is not one stored + // day: its first stops belong to yesterday. The new stop goes in front of the one it + // was measured against, so it is that stop's own day and position that place it — + // written against the card's day it would land in the wrong list, at an index that + // means something else there. + const anchor = day.stops[at] + if (!anchor) return + refuel.close() + setStopDraft({ poi, dayId: anchor.ownerDayId, position: anchor.ownerIndex, 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) => { @@ -1800,7 +2025,7 @@ export function useTripPlanner() { selectedDayId, isLoading, tripActions, can, canUploadFiles, pushUndo, undo, canUndo, lastActionLabel, handleUndo, enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations, - roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor, + roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor, overviewShown, toggleOverview, overviewActive, tripOverview, followTrack, roadtripViaCounts, allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations, @@ -1815,9 +2040,11 @@ export function useTripPlanner() { placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId, stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, setRoadtripStopKind, + setRoadtripStopFill, saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, - routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, + refuel, askRefuel, acceptRefuel, + 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 b3450c1d7..4df5ea76e 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -61,6 +61,18 @@ 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: '', + // Empty = not said, and then a stop of either kind fills up. See refuelsRange(). + roadtrip_vehicle: '', + // Off = the rail routes a day at a time, the way the day plan stores it, and the road + // between one day's last stop and the next day's first is simply not drawn. On = the + // trip is one continuous drive and every one of those gaps is routed too. + roadtrip_connect_days: false, + // Off = one blue line for the whole trip. On = a colour per day, so a route drawn end + // to end can still be read as days. + roadtrip_day_colors: false, dark_mode: false, // Empty = no personal display currency, so Costs falls back to the trip's own. default_currency: '', 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/src/types.ts b/client/src/types.ts index 72f55b0ef..39ae6ca02 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,61 @@ 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 + /** + * 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 + /** Route the gaps between days too, so the trip is one continuous drive. */ + roadtrip_connect_days?: boolean + /** Draw each day of the trip in its own colour. */ + roadtrip_day_colors?: boolean + /** + * How full a fill-up goes, 1 to 100. Absent or 100 means all the way. + * + * Nobody charges to 100 % on the road, so an electric traveller who leaves this at full + * gets a range figure a fifth too generous after every stop. + */ + roadtrip_fill_percent?: number + /** + * What the vehicle is made of, for travellers who would rather not do the division. + * + * Optional throughout, and stored METRIC throughout — litres and kilowatt-hours, per + * 100 kilometres — exactly as roadtrip_range_km is always kilometres. The dialog does + * the round trip into gallons, miles per gallon and kWh per 100 miles for an imperial + * traveller; storage stays one system so the arithmetic never has to ask. + * + * When a pair is complete it WINS over roadtrip_range_km, because it is the more + * specific answer. Half a pair computes nothing: a made-up range here would put the + * fuel warnings at the wrong place while looking exact. + */ + roadtrip_tank_litres?: number + roadtrip_litres_per_100?: number + roadtrip_battery_kwh?: number + roadtrip_kwh_per_100?: number + /** + * Percent of the battery the years have taken. Absent means none. + * + * The one figure of ABRP's list that changes the answer here. Plug type, reference + * speed and drive style shape a consumption PREDICTION; this addon does not predict + * one, it divides a capacity by a consumption the traveller states. Degradation is + * different in kind: it shrinks the capacity itself, and on a five-year-old car by + * enough to decide whether the last leg of a day arrives. + */ + roadtrip_battery_degradation?: number /** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */ carto_api_key?: string mapbox_access_token?: string @@ -228,8 +289,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/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/client/src/utils/reservationRoutes.test.ts b/client/src/utils/reservationRoutes.test.ts index 1f0be69ec..38405e767 100644 --- a/client/src/utils/reservationRoutes.test.ts +++ b/client/src/utils/reservationRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { isRoutableReservation, visibleRouteReservations } from './reservationRoutes' +import { hopIsVisible, isRoutableReservation, labelFloorPx, lineFloorPx, visibleRouteReservations } from './reservationRoutes' import type { Day, Reservation, ReservationEndpoint } from '../types' function endpoint(role: 'from' | 'to', lat: number, lng: number): ReservationEndpoint { @@ -116,3 +116,41 @@ describe('visibleRouteReservations with a selected day', () => { expect(visibleRouteReservations([r], { visibleConnectionIds: [], showTransitRoutes: true, selectedDayId: null, days })).toEqual([r]) }) }) + +describe('hopIsVisible (#2275)', () => { + // A flat projection: one unit of lat or lng is one pixel. + const px = (p: readonly [number, number]) => ({ x: p[1], y: p[0] }) + + it('keeps the type floors of the old endpoint check', () => { + expect(lineFloorPx('car')).toBe(80) + expect(lineFloorPx('flight')).toBe(50) + expect(lineFloorPx('cruise')).toBe(150) + expect(lineFloorPx('train')).toBe(200) + expect(labelFloorPx('car')).toBe(150) + expect(labelFloorPx('transit')).toBe(900) + expect(labelFloorPx('bus')).toBe(400) + }) + + it('measures a straight two-point line exactly as the endpoint gap', () => { + expect(hopIsVisible('car', [[[0, 0], [0, 79]]], px)).toBe(false) + expect(hopIsVisible('car', [[[0, 0], [0, 80]]], px)).toBe(true) + }) + + it('walks a routed line, so a loop between close ends counts its full length', () => { + const loop: [number, number][] = [[0, 0], [30, 0], [30, 30], [0, 30], [0, 5]] + expect(hopIsVisible('car', [loop], px)).toBe(true) + expect(hopIsVisible('train', [loop], px)).toBe(false) + }) + + it('adds the legs of a multi-stop booking together', () => { + const legs: [number, number][][] = [[[0, 0], [0, 50]], [[0, 50], [0, 100]]] + expect(hopIsVisible('car', legs, px)).toBe(false) + expect(hopIsVisible('car', [[[0, 0], [0, 50], [0, 100]]], px)).toBe(true) + }) + + it('draws nothing for an empty or one-point line', () => { + expect(hopIsVisible('car', [], px)).toBe(false) + expect(hopIsVisible('car', [[]], px)).toBe(false) + expect(hopIsVisible('car', [[[0, 0]]], px)).toBe(false) + }) +}) diff --git a/client/src/utils/reservationRoutes.ts b/client/src/utils/reservationRoutes.ts index 3f5cc5f2f..94645d0ae 100644 --- a/client/src/utils/reservationRoutes.ts +++ b/client/src/utils/reservationRoutes.ts @@ -52,3 +52,63 @@ export function visibleRouteReservations(reservations: Reservation[], options: R set.has(r.id) ) } + +/** The type-specific floors, in screen pixels, under which a hop is not worth drawing. */ +const LINE_FLOOR_PX: Record = { flight: 50, cruise: 150, car: 80 } +const LINE_FLOOR_DEFAULT_PX = 200 + +/** Under this floor the endpoint labels stay off, so short hops do not stack text. */ +const LABEL_FLOOR_PX: Record = { flight: 50, cruise: 300, car: 150, transit: 900 } +const LABEL_FLOOR_DEFAULT_PX = 400 + +/** How far the line is allowed to disappear before its endpoints cannot be told apart. */ +export function lineFloorPx(type: string): number { + return LINE_FLOOR_PX[type] ?? LINE_FLOOR_DEFAULT_PX +} + +export function labelFloorPx(type: string): number { + return LABEL_FLOOR_PX[type] ?? LABEL_FLOOR_DEFAULT_PX +} + +/** + * Whether a hop is worth drawing at the current zoom. + * + * The declutter exists for a tiny straight connector, the kind that would sit + * under its own endpoint markers and add nothing but clutter. It used to be + * decided from the two endpoints alone, which was fine while every hop was a + * straight line: the line was never longer than the gap it bridged. + * + * A routed car booking is not that line. It follows the road, and a road that + * leaves a town, loops round a lake and comes back to the next town along + * can run across half the screen while its two ends project a handful of + * pixels apart. The old check then hid the whole drive, and the person looking + * at their trip saw a gap in the route until they zoomed in far enough for + * the endpoints to separate (#2275). A booking with stops on the way has the + * same shape: the ends may be close, the drawn path is not. + * + * So the decision looks at what will actually be drawn. A hop stays hidden + * only while every polyline it would draw is shorter on screen than the + * floor, walked point to point. A straight two-point line measures the same + * as before, so every existing floor holds for the case it was written for. + * + * `project` is whichever renderer's own projection: Leaflet's + * latLngToContainerPoint or Mapbox's project, wrapped to take [lat, lng]. + */ +export function hopIsVisible( + type: string, + lines: readonly (readonly [number, number][])[], + project: (point: readonly [number, number]) => { x: number; y: number }, +): boolean { + const floor = lineFloorPx(type) + for (const line of lines) { + let length = 0 + let prev = line.length > 0 ? project(line[0]) : null + for (let i = 1; i < line.length && prev; i++) { + const next = project(line[i]) + length += Math.hypot(next.x - prev.x, next.y - prev.y) + if (length >= floor) return true + prev = next + } + } + return false +} 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/client/tests/helpers/msw/handlers/index.ts b/client/tests/helpers/msw/handlers/index.ts index 42b0395e8..87c80f1a6 100644 --- a/client/tests/helpers/msw/handlers/index.ts +++ b/client/tests/helpers/msw/handlers/index.ts @@ -5,6 +5,7 @@ import { notificationHandlers } from './notifications'; import { vacayHandlers } from './vacay'; import { tripsHandlers } from './trips'; import { placesHandlers } from './places'; +import { pluginSearchHandlers } from './pluginSearch'; import { assignmentsHandlers } from './assignments'; import { packingHandlers } from './packing'; import { todoHandlers } from './todo'; @@ -25,6 +26,7 @@ export const defaultHandlers = [ ...vacayHandlers, ...tripsHandlers, ...placesHandlers, + ...pluginSearchHandlers, ...assignmentsHandlers, ...packingHandlers, ...todoHandlers, diff --git a/client/tests/helpers/msw/handlers/pluginSearch.ts b/client/tests/helpers/msw/handlers/pluginSearch.ts new file mode 100644 index 000000000..6c56d45a8 --- /dev/null +++ b/client/tests/helpers/msw/handlers/pluginSearch.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from 'msw'; + +/** + * The search-provider route, answering the way an instance with no such plugin does. + * + * `mapsApi.search` asks this beside every place search (#2221), so without a default + * handler every suite that searches for a place has an unhandled request hanging in it + * until its own deadline — which is a slow test with a warning in it, not a real + * failure, and the kind that later reads as flake. + * + * Empty is the honest default: a plugin providing a search index is the exception, and + * a suite that wants hits says so with its own `server.use`. + */ +export const pluginSearchHandlers = [ + http.get('/api/plugin-search', () => HttpResponse.json({ places: [] })), +]; 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/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/plugin-sdk/README.md b/plugin-sdk/README.md index 5bfa8d3f7..0a15e485d 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -225,6 +225,7 @@ skipped, never fatal): | `mapMarkerProvider` | ≤200 markers per provider | | `warningProvider` | ≤20 warnings per provider, each message ≤300 chars | | `placeDetailProvider` | ≤12 items per provider | +| `searchProvider` | ≤20 places per provider, 2 s to answer | | `photoProvider` | ≤60 photos per page | | `calendarSource` | ≤500 events per source per request | | `tableContributor` | ≤20 columns / ≤10 actions per entity | diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 61fbcabc6..9f596127b 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -212,6 +212,7 @@ export const PERMISSION_FAMILIES: PermissionFamily[] = [ { value: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, { value: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, { value: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, + { value: 'hook:search-provider', hint: 'Answer place searches from your own index, beside the core results' }, { value: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, { value: 'hook:table-contributor', hint: 'Add columns to TREK\'s tables' }, { value: 'hook:map-marker-provider', hint: 'Add your own markers to the map' }, diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index 6cd9656bd..e48b12ceb 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -6,6 +6,7 @@ export const HOOK_PERMISSION: Readonly> = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', @@ -65,6 +66,7 @@ export const KNOWN_PERMISSIONS: string[] = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 7a45d0021..1b277f8ee 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -476,6 +476,45 @@ export interface PlaceDetailProvider { * The host caps results at 12 items per provider. */ getDetails(placeId: number, ctx: PluginContext): Promise; } +/** + * A place a search provider found, in the shape the host turns into a search row. + * + * `rating` is the one field open data cannot answer, and the reason this hook exists: + * OpenStreetMap carries no ratings at all, so "the best one around here" needs an + * index that has them. Zero to five, the scale every such index uses. + */ +export interface SearchResultPlace { + /** Stable id in your own index. The host namespaces it as `plugin::`. */ + id?: string; + name: string; + lat: number; + lng: number; + address?: string; + rating?: number; + website?: string; + phone?: string; + category?: string; + description?: string; +} +/** What the host asks a search provider to look for. */ +export interface SearchRequest { + query: string; + /** The most rows worth returning. The host caps it at 20 whatever it is asked for. */ + limit: number; + /** The caller's language tag, for indexes that carry localized names. */ + lang?: string; + /** Where the person is looking, when there is somewhere to bias toward. */ + near?: { lat: number; lng: number }; +} +export interface SearchProvider { + /** Places for a query, drawn into TREK's own search list beside the core results. + * Needs `hook:search-provider`. The host caps results at 20 places per provider and + * gives you 2 seconds to answer, because somebody is waiting on the list. + * + * Called for an explicit search, not for every keystroke: an external index has rate + * limits, and a request per typed letter would spend them on words nobody finished. */ + search(request: SearchRequest, ctx: PluginContext): Promise; +} /** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } export interface WarningProvider { @@ -828,6 +867,7 @@ export interface PluginDefinition { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; placeDetailProvider?: PlaceDetailProvider; + searchProvider?: SearchProvider; warningProvider?: WarningProvider; tableContributor?: TableContributor; mapMarkerProvider?: MapMarkerProvider; 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/db/migrations.ts b/server/src/db/migrations.ts index 52e79a542..d9746f80a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4483,6 +4483,57 @@ 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)'); + }, + /** + * 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/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 06665600f..7094342e1 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -235,9 +235,22 @@ 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/", + // 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/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/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/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/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/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 084acffd3..016f6c40c 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[]; }), @@ -2018,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 @@ -2036,14 +2039,59 @@ 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 : '', + // 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', }; } @@ -2109,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/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/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 03325847b..e747bce78 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 }); @@ -290,7 +292,7 @@ export class PlacesMcp { @Tool({ name: 'search_place', - description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent.', + description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.', inputSchema: { query: z.string().min(1).max(500).describe('Place name or address to search for'), locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'), @@ -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/nest/plugins/contributions/plugin-contributions.module.ts b/server/src/nest/plugins/contributions/plugin-contributions.module.ts index f27406576..c1bfc30b3 100644 --- a/server/src/nest/plugins/contributions/plugin-contributions.module.ts +++ b/server/src/nest/plugins/contributions/plugin-contributions.module.ts @@ -3,8 +3,10 @@ import { PluginsRuntimeModule } from '../plugins-runtime.module'; import { AddonsModule } from '../../addons/addons.module'; import { JourneyDomainModule } from '../../journey/journey-domain.module'; import { PlaceDetailsController } from './place-details.controller'; +import { PluginSearchController } from './plugin-search.controller'; import { TripWarningsController } from './trip-warnings.controller'; import { TripWarningsMcp } from './trip-warnings.mcp'; +import { PluginSearchMcp } from './plugin-search.mcp'; import { PluginMcpToolsService } from './plugin-mcp-tools.service'; import { ViewContributionsController } from './view-contributions.controller'; import { TripCardContributionsController } from './trip-card-contributions.controller'; @@ -21,8 +23,9 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; /** * The read-only surface plugins contribute to the app: photos, calendar events, - * place details, trip warnings, table columns, map markers and layers, routes, day - * schedules and tints, PDF sections, atlas layers, journal entry rows, trip cards. + * place details, search results, trip warnings, table columns, map markers and + * layers, routes, day schedules and tints, PDF sections, atlas layers, journal entry + * rows, trip cards. * * Every one of these is the same shape — fan out over `providersOf(hook)`, call the * hook through `PluginHooks`, normalize and cap what comes back, skip a provider that @@ -34,6 +37,7 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule], controllers: [ PlaceDetailsController, + PluginSearchController, TripWarningsController, ViewContributionsController, TripCardContributionsController, @@ -55,6 +59,6 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; // PluginMcpToolsService owns the process-level tool source. It lives here, and // not on PluginRuntimeService beside the other sinks, because it needs // PluginHooks and PluginHooks injects PluginRuntimeService. - providers: [TripWarningsMcp, PluginMcpToolsService], + providers: [TripWarningsMcp, PluginSearchMcp, PluginMcpToolsService], }) export class PluginContributionsModule {} diff --git a/server/src/nest/plugins/contributions/plugin-search.controller.ts b/server/src/nest/plugins/contributions/plugin-search.controller.ts new file mode 100644 index 000000000..b53d6f9aa --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { pluginsEnabled } from '../kill-switch'; +import { PluginHooks } from '../plugin-hooks.service'; +import { + interleave, + limitFrom, + MAX_QUERY, + nearFrom, + normalizeSearchHits, + type SearchHit, +} from './plugin-search.helpers'; + +/** + * GET /api/plugin-search — places found by plugins implementing `searchProvider` (#2221). + * + * A search index TREK does not ship. The core search asks the TREK index and + * OpenStreetMap; this asks whatever else the operator installed, and the client draws + * one list out of both. Additive and fail-safe like the other provider-hook + * controllers: a provider that errors or times out contributes nothing and the search + * is drawn without it. + * + * NOT trip-scoped, unlike its neighbours here. Searching for a place is not a read of + * anybody's trip: the query is the caller's own words and the answer is a public index, + * so the gate is being signed in. Nothing about a trip is passed to the plugin, which + * is also what keeps a search provider from becoming a way to enumerate one. + * + * A route of its own beside `POST /api/maps/search` rather than a branch inside it, + * for the reason `trip-warnings.mcp.ts` documents next door: the plugin runtime already + * imports PlacesModule, which imports MapsModule, so asking the providers from inside + * the maps service would close a module cycle. The client calls both and merges. + */ +@Controller('api/plugin-search') +@UseGuards(JwtAuthGuard) +export class PluginSearchController { + constructor(private readonly hooks: PluginHooks) {} + + @Get() + async search( + @Query('q') q: string | undefined, + @Query('lat') lat: string | undefined, + @Query('lng') lng: string | undefined, + @Query('lang') lang: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: Request & { user?: { id: number } }, + ): Promise<{ places: SearchHit[] }> { + if (!pluginsEnabled()) return { places: [] }; + const userId = req.user?.id; + const query = String(q ?? '').trim().slice(0, MAX_QUERY); + // An empty query is not an error here: the client fires this beside every search, + // and a blank box simply has nothing to ask a provider about. + if (!query || userId == null) return { places: [] }; + + const ids = this.hooks.providersOf('searchProvider'); + if (ids.length === 0) return { places: [] }; + + const request = { + query, + limit: limitFrom(limit), + lang: lang ? String(lang).slice(0, 20) : undefined, + near: nearFrom(lat, lng), + }; + const results = await Promise.all( + ids.map(async (id): Promise => { + try { + return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, userId)); + } catch { + return []; // a slow / failing provider is skipped, never fatal + } + }), + ); + return { places: interleave(results) }; + } +} diff --git a/server/src/nest/plugins/contributions/plugin-search.helpers.ts b/server/src/nest/plugins/contributions/plugin-search.helpers.ts new file mode 100644 index 000000000..5d518d7e1 --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.helpers.ts @@ -0,0 +1,135 @@ +import { stripEmoji } from '../text-sanitize'; + +/** + * Turning what a `searchProvider` plugin answers into rows TREK can show (#2221). + * + * Shared by the REST route and the MCP tool rather than written twice: the two are the + * same question asked by a browser and by an assistant, and a cap or a url check that + * held on one path but not the other would be a hole with a second door. + * + * Every hit is normalized into the record shape the core search returns (`toPlaceRecord` + * in maps/trek-places.client.ts), because the callers read `name`, `lat`, `lng` and + * `address` off whatever the list holds and must not need to know which index a row + * came from. + */ +export interface SearchHit { + osm_id: string; + name: string; + address: string; + lat: number; + lng: number; + /** What a plugin index can answer that open data cannot. Null when it does not. */ + rating: number | null; + website: string | null; + phone: string | null; + category: string | null; + description: string | null; + /** `plugin:`, so a row can name the index it came from. */ + source: string; + pluginId: string; +} + +/** Per provider. Bounds one plugin's share of a list a person reads. */ +export const MAX_HITS = 20; +/** The most one provider may be asked for, whatever the caller puts in the query. */ +export const MAX_LIMIT = 20; +export const DEFAULT_LIMIT = 10; +/** Long enough for a full address line, short enough that no row can flood the list. */ +export const MAX_QUERY = 200; + +const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); + +const capOrNull = (v: unknown, n: number): string | null => { + if (v == null) return null; + const s = cap(v, n); + return s === '' ? null : s; +}; + +/** + * A url that may become an ``. Anything but http/https is dropped — a + * `javascript:` or `data:` url rendered as a link is click-XSS into the search list. + */ +function safeUrl(raw: unknown): string | null { + if (typeof raw !== 'string' || raw === '') return null; + try { + const u = new URL(raw); + return u.protocol === 'http:' || u.protocol === 'https:' ? raw.slice(0, 2048) : null; + } catch { + return null; + } +} + +/** + * A rating a plugin index carries, on the five-point scale every such index uses. + * + * Clamped rather than rejected: a provider answering on a ten-point scale is a bug in + * that provider, and dropping the whole hit over it would lose the place as well. + */ +function safeRating(raw: unknown): number | null { + const n = Number(raw); + if (!Number.isFinite(n)) return null; + return Math.min(5, Math.max(0, Math.round(n * 10) / 10)); +} + +export function normalizeSearchHits(pluginId: string, raw: unknown): SearchHit[] { + const list = Array.isArray(raw) ? (raw as Array>) : []; + const out: SearchHit[] = []; + for (const h of list) { + if (out.length >= MAX_HITS) break; + if (!h || typeof h !== 'object') continue; + const lat = Number(h.lat); + const lng = Number(h.lng); + const name = cap(h.name, 200); + // A hit with no name or no place on the earth cannot be shown or picked. + if (!name || !Number.isFinite(lat) || !Number.isFinite(lng)) continue; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) continue; + const id = cap(h.id, 100); + out.push({ + // Namespaced so a plugin id can never collide with a real OSM id or a `gers:` + // one from the index. Falls back to the coordinate when the plugin has no stable + // id of its own, which still tells two rows apart. + osm_id: `plugin:${pluginId}:${id || `${lat},${lng}`}`, + name, + address: cap(h.address, 300), + lat, + lng, + rating: safeRating(h.rating), + website: safeUrl(h.website), + phone: capOrNull(h.phone, 60), + category: capOrNull(h.category, 60), + description: capOrNull(h.description, 500), + source: `plugin:${pluginId}`, + pluginId, + }); + } + return out; +} + +/** The bias, when the caller supplied a real coordinate rather than two strings. */ +export function nearFrom(latRaw: unknown, lngRaw: unknown): { lat: number; lng: number } | undefined { + const lat = Number(latRaw); + const lng = Number(lngRaw); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return undefined; + return { lat, lng }; +} + +export function limitFrom(raw: unknown): number { + const asked = Number(raw); + return Number.isFinite(asked) && asked > 0 ? Math.min(MAX_LIMIT, Math.trunc(asked)) : DEFAULT_LIMIT; +} + +/** + * One list out of several providers, taking a row from each in turn. + * + * Interleaved rather than concatenated: with two indexes installed, a plain + * concatenation buries the second one's best hit under twenty of the first one's. + */ +export function interleave(results: SearchHit[][]): SearchHit[] { + const out: SearchHit[] = []; + for (let i = 0; ; i += 1) { + const row = results.filter(r => r.length > i); + if (row.length === 0) return out; + for (const r of row) out.push(r[i]); + } +} diff --git a/server/src/nest/plugins/contributions/plugin-search.mcp.ts b/server/src/nest/plugins/contributions/plugin-search.mcp.ts new file mode 100644 index 000000000..056e5f27f --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.mcp.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp'; +import { pluginsEnabled } from '../kill-switch'; +import { PluginHooks } from '../plugin-hooks.service'; +import { + interleave, + limitFrom, + MAX_QUERY, + nearFrom, + normalizeSearchHits, + type SearchHit, +} from './plugin-search.helpers'; + +/** + * The MCP half of GET /api/plugin-search (#2221). + * + * The browser merges the plugin hits into its own search list, so a person searching + * for a hotel sees the installed index alongside OpenStreetMap without asking. An + * assistant calling `search_place` saw only TREK's own indexes, which is the same + * asymmetry `get_trip_warnings` exists to close: what the user can see, the assistant + * has to be able to see. + * + * A tool of its own rather than a section of `search_place`, for the reason spelled out + * in trip-warnings.mcp.ts: `search_place` lives in PlacesModule, the plugin runtime + * already imports PlacesModule, and reaching the hooks from there would close a module + * cycle. It is also a different cost — an IPC round trip into every installed provider + * with a four-second budget, against a synchronous call into the maps service. + */ +@McpController() +export class PluginSearchMcp { + constructor(private readonly hooks: PluginHooks) {} + + @Tool({ + name: 'search_places_via_plugins', + description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.', + inputSchema: { + query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'), + near: z.object({ lat: z.number(), lng: z.number() }) + .optional() + .describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'), + lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'), + limit: z.number().int().positive().max(20).optional().describe('How many results per provider (default 10, capped at 20)'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'places', mode: 'read' }, + }) + async searchViaPlugins( + { query, near, lang, limit }: { + query: string; near?: { lat: number; lng: number }; lang?: string; limit?: number; + }, + ctx: McpContext, + ): Promise { + if (!pluginsEnabled()) return ok({ places: [] }); + const ids = this.hooks.providersOf('searchProvider'); + if (ids.length === 0) return ok({ places: [] }); + + // Built through the same helpers the REST route uses, so a cap that holds for the + // browser holds for the assistant. `near` arrives typed here and still goes through + // nearFrom, because a schema that accepts any number still accepts latitude 91. + const request = { + query: query.trim().slice(0, MAX_QUERY), + limit: limitFrom(limit), + lang, + near: nearFrom(near?.lat, near?.lng), + }; + const results = await Promise.all( + ids.map(async (id): Promise => { + try { + return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, ctx.userId)); + } catch { + return []; // a slow or failing provider contributes nothing, it does not fail the tool + } + }), + ); + return ok({ places: interleave(results) }); + } +} diff --git a/server/src/nest/plugins/plugin-hooks.service.ts b/server/src/nest/plugins/plugin-hooks.service.ts index 376785421..b6a6a4ff3 100644 --- a/server/src/nest/plugins/plugin-hooks.service.ts +++ b/server/src/nest/plugins/plugin-hooks.service.ts @@ -18,6 +18,21 @@ export interface HookMcpToolCall { args: unknown; } +/** + * What a search provider is asked to look for. + * + * `near` is the same bias the core search uses, and it is what decides which of the + * four places called "Hase-dera" the answer is about. Optional, because a search from + * a trip with no places yet has nothing to bias toward. + */ +export interface HookSearchRequest { + query: string; + limit: number; + /** The caller's language tag, for providers that return localized names. */ + lang?: string; + near?: { lat: number; lng: number }; +} + /** The waypoint request a route provider is asked to solve. */ export interface HookRouteRequest { tripId: number; @@ -29,7 +44,7 @@ export interface HookRouteRequest { /** * Every host-to-plugin hook call, in one place. * - * The 15 hooks the consent screen offers used to be invoked straight from the + * The 16 hooks the consent screen offers used to be invoked straight from the * controllers, with the fn name and the timeout written out at each call site. That * made three things impossible to check: that a granted `hook:*` permission actually * has a consumer (a dead grant on the consent screen looks exactly like a live one), @@ -81,6 +96,20 @@ export class PluginHooks { return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000); } + /** + * The shortest leash of any hook here, because a person is waiting on a list. + * + * The core search and this one run side by side, so the wait is the slower of the + * two rather than their sum, and the client stops waiting at two and a half seconds + * whatever happens. Two seconds is therefore the whole of what a provider can + * usefully spend: past it the list is already drawn and its answer is discarded, so + * a longer budget would only hold an IPC call open for nothing. + */ + @PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'search', timeoutMs: 2000 }) + searchPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000); + } + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 }) tripWarnings(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000); diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 8d4c338e1..b01e52882 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -20,7 +20,7 @@ import { AppConfigModule } from '../app-config/app-config.module'; * * - `PluginsRuntimeModule` — supervisor, capability router, hook contracts. The half * with the domain imports, and the only half AdminModule needs. - * - `PluginContributionsModule` — the 14 read-only hook controllers. + * - `PluginContributionsModule` — the 15 read-only hook controllers. * - `PluginOAuthModule` — the outbound-OAuth leaf. * * What is left here is the CRUD and delivery surface: install/activate/configure, the diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index c4822376a..c32a7a9d8 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -358,6 +358,7 @@ export const KNOWN_PERMISSIONS = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', @@ -430,6 +431,7 @@ export const HOOK_PERMISSION = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index a8a324424..42df67ccb 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -414,6 +414,39 @@ export interface PlaceDetailItem { label: string; value?: string; url?: string; export interface PlaceDetailProvider { getDetails(placeId: number, ctx: PluginContext): Promise; } +/** + * A place a search provider found, in the shape the host turns into a search row. + * + * `rating` is the one field open data cannot answer, and the reason this hook exists: + * OpenStreetMap carries no ratings at all, so "the best one around here" needs an + * index that has them. Zero to five, the scale every such index uses. + */ +export interface SearchResultPlace { + /** Stable id in your own index. The host namespaces it as `plugin::`. */ + id?: string; + name: string; + lat: number; + lng: number; + address?: string; + rating?: number; + website?: string; + phone?: string; + category?: string; + description?: string; +} +/** What the host asks a search provider to look for. */ +export interface SearchRequest { + query: string; + /** The most rows worth returning. The host caps it at 20 whatever it is asked for. */ + limit: number; + /** The caller's language tag, for indexes that carry localized names. */ + lang?: string; + /** Where the person is looking, when there is somewhere to bias toward. */ + near?: { lat: number; lng: number }; +} +export interface SearchProvider { + search(request: SearchRequest, ctx: PluginContext): Promise; +} /** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } export interface WarningProvider { @@ -721,6 +754,7 @@ export interface PluginDefinition { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; placeDetailProvider?: PlaceDetailProvider; + searchProvider?: SearchProvider; warningProvider?: WarningProvider; tableContributor?: TableContributor; mapMarkerProvider?: MapMarkerProvider; diff --git a/server/src/nest/roadtrip/roadtrip.controller.ts b/server/src/nest/roadtrip/roadtrip.controller.ts index abe3e1363..7efcb7042 100644 --- a/server/src/nest/roadtrip/roadtrip.controller.ts +++ b/server/src/nest/roadtrip/roadtrip.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common'; import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared'; import { RoadtripService } from './roadtrip.service'; import { RoadtripViaBatchDto, RoadtripViaCreateDto, RoadtripViaReanchorDto, RoadtripViaUpdateDto } from './roadtrip.dto'; @@ -14,11 +14,12 @@ import { ADDON_IDS } from '../../addons'; * Gated on the road trip addon: an instance with it switched off gets 404s here, the same * as every other addon surface, and nothing about the plain day plan changes. * - * No WebSocket broadcast, unlike the assignment routes. A via is a detail of how one - * person's route is drawn rather than a change to the itinerary everybody reads, and - * adding an event family for it would mean extending the typed trip-event catalogue for - * something a collaborator sees on their next load anyway. Worth revisiting if vias ever - * become shared editing rather than route shaping. + * Every write here broadcasts, like the assignment routes. This used to be silent, on + * the reasoning that a via is how one person draws their route rather than a change to + * the itinerary everybody reads. That does not survive contact with two people planning + * one road trip: they look at the same line on the same map, and a reshaped drive moves + * every arrival time after it. Waiting for the other side's next reload is not "eventual + * consistency", it is two people editing a route neither can see the other change. */ @Controller('api/trips/:tripId/roadtrip') // AddonGuard FIRST: @RequireAddon is metadata and does nothing on its own, and the guard @@ -48,9 +49,12 @@ export class RoadtripController { @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaCreateDto, + @Headers('x-socket-id') socketId?: string, ): { via: RoadtripVia } { this.requireDay(dayId, tripId); - return { via: this.roadtrip.create(dayId, body) }; + const via = this.roadtrip.create(dayId, body); + this.announce(tripId, dayId, socketId); + return { via }; } /** @@ -68,6 +72,7 @@ export class RoadtripController { @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaBatchDto, + @Headers('x-socket-id') socketId?: string, ): { vias: RoadtripVia[] } { this.requireDay(dayId, tripId); // Permission is not enough on its own: a place id from somebody else's trip would @@ -76,7 +81,15 @@ export class RoadtripController { if (body.track && !this.roadtrip.trackExists(body.track.place_id, tripId)) { throw new HttpException({ error: 'Track not found' }, 404); } - return { vias: this.roadtrip.createMany(dayId, body) }; + const vias = this.roadtrip.createMany(dayId, body); + this.announce(tripId, dayId, socketId); + // A batch is how a day starts following a recorded track, so the track it now follows + // is news in its own right: the rail draws a badge for it and the map a line. + this.roadtrip.broadcast(tripId, 'roadtripTrack:changed', { + dayId, + track: this.roadtrip.tracksForTrip(tripId).find(t => String(t.day_id) === String(dayId)) ?? null, + }, socketId); + return { vias }; } /** @@ -93,9 +106,12 @@ export class RoadtripController { @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Body() body: RoadtripViaReanchorDto, + @Headers('x-socket-id') socketId?: string, ): { vias: RoadtripVia[] } { this.requireDay(dayId, tripId); - return { vias: this.roadtrip.reanchor(dayId, body) }; + const vias = this.roadtrip.reanchor(dayId, body); + this.announce(tripId, dayId, socketId); + return { vias }; } @RequirePermission('day_edit') @@ -106,10 +122,12 @@ export class RoadtripController { @Param('dayId') dayId: string, @Param('id') id: string, @Body() body: RoadtripViaUpdateDto, + @Headers('x-socket-id') socketId?: string, ): { 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); + this.announce(tripId, dayId, socketId); return { via }; } @@ -119,14 +137,31 @@ export class RoadtripController { @Param('tripId') tripId: string, @Param('dayId') dayId: string, @Param('id') id: string, + @Headers('x-socket-id') socketId?: string, ): { success: true } { this.requireDay(dayId, tripId); if (!this.roadtrip.remove(id, dayId)) { throw new HttpException({ error: 'Via not found' }, 404); } + this.announce(tripId, dayId, socketId); return { success: true }; } + /** + * Says what this day's drive is routed through now. + * + * Read back rather than assembled from what the write returned: a reanchor rewrites the + * whole set, a batch may clear legs before filling them, and one shape for all five + * routes means a client applies them all the same way. The originating socket is + * excluded, so the person dragging does not get their own point handed back mid-drag. + */ + private announce(tripId: string, dayId: string, socketId: string | undefined): void { + this.roadtrip.broadcast(tripId, 'roadtripVia:changed', { + dayId, + vias: this.roadtrip.listForDay(dayId), + }, socketId); + } + /** * The guard proves the caller may reach the trip; this proves the day is part of it. * Without it a valid day id from someone else's trip would be editable through a trip diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index 2778d27d3..c7b08e084 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -77,7 +77,9 @@ export class RoadtripMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - return ok({ via: this.roadtrip.create(dayId, { after_order_index, lat, lng }) }); + const via = this.roadtrip.create(dayId, { after_order_index, lat, lng }); + this.announce(tripId, dayId); + return ok({ via }); } @Tool({ @@ -121,7 +123,13 @@ export class RoadtripMcp { // a place id from another trip, or a place that is not a track at all, become this // day's label. if (track && !this.roadtrip.trackExists(track.place_id, tripId)) return noAccess(); - return ok({ vias: this.roadtrip.createMany(dayId, { vias, replace_legs, track }) }); + const made = this.roadtrip.createMany(dayId, { vias, replace_legs, track }); + this.announce(tripId, dayId); + this.roadtrip.broadcast(String(tripId), 'roadtripTrack:changed', { + dayId, + track: this.roadtrip.tracksForTrip(String(tripId)).find(t => String(t.day_id) === String(dayId)) ?? null, + }, undefined); + return ok({ vias: made }); } @Tool({ @@ -149,7 +157,9 @@ export class RoadtripMcp { if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); - return ok({ vias: this.roadtrip.reanchor(dayId, { vias, remove }) }); + const next = this.roadtrip.reanchor(dayId, { vias, remove }); + this.announce(tripId, dayId); + return ok({ vias: next }); } @Tool({ @@ -170,6 +180,22 @@ export class RoadtripMcp { if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); if (!this.roadtrip.remove(viaId, dayId)) return noAccess(); + this.announce(tripId, dayId); return ok({ success: true }); } + + /** + * Says what this day's drive is routed through now. + * + * The same announcement the REST routes make, because it is the same change: a via laid + * by an assistant moves the line on everybody's map exactly as one dragged by hand. + * No originating socket to exclude here — a tool call has no socket of its own, so the + * client that asked for it hears about it like everyone else. + */ + private announce(tripId: number, dayId: number): void { + this.roadtrip.broadcast(String(tripId), 'roadtripVia:changed', { + dayId, + vias: this.roadtrip.listForDay(String(dayId)), + }, undefined); + } } diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 65723a76c..5cdfeecbd 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -5,13 +5,14 @@ import { RoadtripMcp } from './roadtrip.mcp'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AuthModule } from '../auth/auth.module'; +import { RealtimeModule } from '../realtime/realtime.module'; import { AddonsModule } from '../addons/addons.module'; /** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */ @Module({ // McpShared brings the tool guards, Auth the demo check, Permissions the trip guard, // Addons the enabled-check the MCP tools gate on (the controller has @RequireAddon). - imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule], + imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule, RealtimeModule], controllers: [RoadtripController], providers: [RoadtripService, RoadtripMcp], exports: [RoadtripService], diff --git a/server/src/nest/roadtrip/roadtrip.service.ts b/server/src/nest/roadtrip/roadtrip.service.ts index 720f8b853..44da412b5 100644 --- a/server/src/nest/roadtrip/roadtrip.service.ts +++ b/server/src/nest/roadtrip/roadtrip.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; -import type { RoadtripDayTrack, RoadtripVia } from '@trek/shared'; +import type { RoadtripDayTrack, RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { DatabaseService } from '../database/database.service'; +import { RealtimeService } from '../realtime/realtime.service'; /** * Via points: the places a day's drive is made to pass through without stopping. @@ -12,7 +13,25 @@ import { DatabaseService } from '../database/database.service'; */ @Injectable() export class RoadtripService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly realtime: RealtimeService, + ) {} + + /** + * Tells the trip's other clients what the drive looks like now. + * + * Two people planning a road trip look at the same line on the same map, and a reshaped + * drive moves every arrival time after it — so this is not "how one person draws their + * route", which is what it was taken for when these routes were written silent. + * + * The whole day's list goes out rather than the one point that changed: a via carries no + * identity anybody reads, the client holds them per day, and a drag is a burst of writes + * whose only interesting state is the one that lands last. + */ + broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { + this.realtime.broadcast(tripId, event, payload, socketId); + } /** The day exists and belongs to this trip. 404 material, checked before every write. */ dayExists(dayId: string | number, tripId: string | number): boolean { @@ -154,14 +173,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/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/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/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/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 278cd8ebe..6b01ef985 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -65,7 +65,7 @@ export class TodoMcp { due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'), description: z.string().max(2000).optional().describe('Additional description'), assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'), - priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'), + priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=P1 (highest), 2=P2, 3=P3 (lowest)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: packingAddonOn, @@ -96,7 +96,7 @@ export class TodoMcp { due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'), description: z.string().max(2000).nullable().optional().describe('Set to null to clear'), assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'), - priority: z.number().int().min(0).max(3).nullable().optional(), + priority: z.number().int().min(0).max(3).nullable().optional().describe('Priority: 0=none, 1=P1 (highest), 2=P2, 3=P3 (lowest); null clears it'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, 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/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/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 89ebe5e19..9bac1ba70 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -102,6 +102,7 @@ import { makeStorageFixture } from './storage-fixture'; // and the warnings tool answers empty by default; the trip-warnings suite spies on // PluginHooks.prototype to play the provider fan-out. import { TripWarningsMcp } from '../../src/nest/plugins/contributions/trip-warnings.mcp'; +import { PluginSearchMcp } from '../../src/nest/plugins/contributions/plugin-search.mcp'; import { PluginHooks } from '../../src/nest/plugins/plugin-hooks.service'; import type { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service'; import { AirtrailMcp } from '../../src/nest/integrations/airtrail.mcp'; @@ -214,7 +215,7 @@ export function createMcpTestRegistry(): McpRegistry { new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards), new DaysMcp(daysService, authService, guards), - new RoadtripMcp(new RoadtripService(dbService), dbService, guards, authService, addonsService), + new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService), new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards), new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards), new AssignmentsMcp(assignmentsService, daysService, authService, guards), @@ -238,6 +239,7 @@ export function createMcpTestRegistry(): McpRegistry { new SettingsMcp(new SettingsService(dbService), authService), new HelpMcp(), new AddonsMcp(addonsService), new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), dbService), + new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/unit/mcp/tools-plugin-search.test.ts b/server/tests/unit/mcp/tools-plugin-search.test.ts new file mode 100644 index 000000000..8b9b3d05e --- /dev/null +++ b/server/tests/unit/mcp/tools-plugin-search.test.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for the search_places_via_plugins MCP tool (src/nest/plugins/ + * contributions/plugin-search.mcp.ts), the MCP counterpart of GET /api/plugin-search. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number, userId: number) => + db + .prepare( + 'SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', + ) + .get(userId, tripId, userId), + isOwner: (tripId: number, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +const { broadcastMock, pluginsEnabled } = vi.hoisted(() => ({ + broadcastMock: vi.fn(), + pluginsEnabled: vi.fn(() => true), +})); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +// The admin kill switch reads live env; drive it from the test instead of the process. +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); + +import { runMigrations } from '../../../src/db/migrations'; +import { createTables } from '../../../src/db/schema'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { resetTestDb } from '../../helpers/test-db'; +import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; + +const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf'); +const searchPlacesMock = vi.spyOn(PluginHooks.prototype, 'searchPlaces'); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + pluginsEnabled.mockReturnValue(true); + providersOfMock.mockReset().mockReturnValue([]); + searchPlacesMock.mockReset().mockResolvedValue([]); +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false, scopes: null }); + try { await fn(h); } finally { await h.cleanup(); } +} + +interface PlacesPayload { + places: { osm_id: string; name: string; rating: number | null; pluginId: string; source: string }[]; +} + +const call = async (h: McpHarness, args: Record) => + h.client.callTool({ name: 'search_places_via_plugins', arguments: args }); + +describe('Tool: search_places_via_plugins', () => { + it('returns what the providers found, with the rating open data cannot carry', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['guide']); + searchPlacesMock.mockResolvedValue([ + { id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6, rating: 4.6, address: 'Lakeside 1' }, + ]); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel lausanne' })) as PlacesPayload; + expect(payload.places).toHaveLength(1); + expect(payload.places[0]).toMatchObject({ + osm_id: 'plugin:guide:h1', + name: 'Hotel Bellevue', + rating: 4.6, + pluginId: 'guide', + source: 'plugin:guide', + }); + expect(providersOfMock).toHaveBeenCalledWith('searchProvider'); + expect(searchPlacesMock).toHaveBeenCalledWith( + 'guide', + { query: 'hotel lausanne', limit: 10, lang: undefined, near: undefined }, + user.id, + ); + }); + }); + + it('passes the bias and the limit through, and refuses a coordinate off the globe', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['guide']); + + await withHarness(user.id, async (h) => { + await call(h, { query: 'hotel', near: { lat: 46.5, lng: 6.6 }, lang: 'fr', limit: 3 }); + expect(searchPlacesMock).toHaveBeenLastCalledWith( + 'guide', + { query: 'hotel', limit: 3, lang: 'fr', near: { lat: 46.5, lng: 6.6 } }, + user.id, + ); + + // The schema accepts any number, so the range check still has to happen here. + await call(h, { query: 'hotel', near: { lat: 91, lng: 6.6 } }); + expect(searchPlacesMock).toHaveBeenLastCalledWith( + 'guide', + { query: 'hotel', limit: 10, lang: undefined, near: undefined }, + user.id, + ); + }); + }); + + it('answers with an empty list when no plugin provides an index, without asking one', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places).toEqual([]); + expect(searchPlacesMock).not.toHaveBeenCalled(); + }); + }); + + it('answers with an empty list while the plugin runtime is switched off', async () => { + const { user } = createUser(testDb); + pluginsEnabled.mockReturnValue(false); + providersOfMock.mockReturnValue(['guide']); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places).toEqual([]); + expect(providersOfMock).not.toHaveBeenCalled(); + }); + }); + + it('skips a provider that fails and still returns the other one', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['broken', 'guide']); + searchPlacesMock.mockImplementation(async (pluginId: string) => { + if (pluginId === 'broken') throw new Error('timed out'); + return [{ id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6 }]; + }); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places.map(p => p.pluginId)).toEqual(['guide']); + }); + }); +}); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 8b768d0bf..18abf4d46 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -52,6 +52,43 @@ 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 + // 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'"); @@ -147,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/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index 719d2282c..a51cc808d 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,63 @@ 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-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-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()]); diff --git a/server/tests/unit/nest/roadtrip.controller.test.ts b/server/tests/unit/nest/roadtrip.controller.test.ts index aaed155f5..b360b9266 100644 --- a/server/tests/unit/nest/roadtrip.controller.test.ts +++ b/server/tests/unit/nest/roadtrip.controller.test.ts @@ -37,6 +37,8 @@ function svc(o: Partial = {}): RoadtripService { reanchor: vi.fn().mockReturnValue([VIA]), move: vi.fn().mockReturnValue(VIA), remove: vi.fn().mockReturnValue(true), + // Every write announces the day's new shape to the trip's other clients. + broadcast: vi.fn(), ...o, } as unknown as RoadtripService; } @@ -160,7 +162,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', () => { @@ -180,4 +190,52 @@ describe('RoadtripController — writes', () => { expect(thrown(() => new RoadtripController(s).remove('7', '4', '999'))) .toEqual({ status: 404, body: { error: 'Via not found' } }); }); + + /** + * Two people planning one road trip look at the same line on the same map, and a + * reshaped drive moves every arrival time after it. These routes used to write in + * silence, which left the other side reading a route nobody could see change. + */ + describe('telling the trip', () => { + it('SRV-ROADTRIP-020: every write announces the new shape of the day', () => { + const cases: [string, (c: RoadtripController) => unknown][] = [ + ['create', c => c.create('7', '4', { after_order_index: 0, lat: 1, lng: 2 }, 'sock')], + ['createMany', c => c.createMany('7', '4', { vias: [{ after_order_index: 0, lat: 1, lng: 2 }] }, 'sock')], + ['reanchor', c => c.reanchor('7', '4', { vias: [] }, 'sock')], + ['update', c => c.update('7', '4', '9', { lat: 1, lng: 2 }, 'sock')], + ['remove', c => c.remove('7', '4', '9', 'sock')], + ]; + for (const [name, run] of cases) { + const s = svc(); + run(new RoadtripController(s)); + expect(s.broadcast, name).toHaveBeenCalledWith( + '7', + 'roadtripVia:changed', + { dayId: '4', vias: [VIA] }, + 'sock', + ); + } + }); + + it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', () => { + // A reanchor rewrites the whole set and a batch may clear legs before filling them, + // so what the write returned is not what the day now holds. + const s = svc({ reanchor: vi.fn().mockReturnValue([]) } as Partial); + new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock'); + expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock'); + }); + + it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', () => { + const track = { day_id: 4, place_id: 3, name: 'B96' }; + const s = svc({ tracksForTrip: vi.fn().mockReturnValue([track]) } as Partial); + new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock'); + expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripTrack:changed', { dayId: '4', track }, 'sock'); + }); + + it('SRV-ROADTRIP-023: a refused write announces nothing', () => { + const s = svc({ remove: vi.fn().mockReturnValue(false) } as Partial); + expect(() => new RoadtripController(s).remove('7', '4', '9', 'sock')).toThrow(); + expect(s.broadcast).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/tests/unit/nest/roadtrip.service.test.ts b/server/tests/unit/nest/roadtrip.service.test.ts index dfa73c02c..833cd644a 100644 --- a/server/tests/unit/nest/roadtrip.service.test.ts +++ b/server/tests/unit/nest/roadtrip.service.test.ts @@ -43,7 +43,9 @@ function makeService() { run: (sql: string, ...params: unknown[]) => raw.prepare(sql).run(...params as never[]), transaction: (fn: (conn: unknown) => T) => raw.transaction(() => fn(raw))(), }; - return new RoadtripService(db as never); + // The realtime side is exercised through the controller and the MCP layer; here the + // service is under test for what it writes, so a broadcast that goes nowhere is right. + return new RoadtripService(db as never, { broadcast: () => {} } as never); } describe('RoadtripService', () => { @@ -82,7 +84,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 +95,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/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/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', () => { diff --git a/server/tests/unit/plugins/plugin-hooks.test.ts b/server/tests/unit/plugins/plugin-hooks.test.ts index ddf53f3af..16cb67058 100644 --- a/server/tests/unit/plugins/plugin-hooks.test.ts +++ b/server/tests/unit/plugins/plugin-hooks.test.ts @@ -32,6 +32,7 @@ const CONTRACTS: Array<[hook: string, fn: string, timeoutMs: number]> = [ ['calendarSource', 'getName', 3000], ['calendarSource', 'getEvents', 5000], ['placeDetailProvider', 'getDetails', 5000], + ['searchProvider', 'search', 2000], ['warningProvider', 'getWarnings', 5000], ['tableContributor', 'getContributions', 5000], ['mapMarkerProvider', 'getMarkers', 5000], diff --git a/server/tests/unit/plugins/plugin-search.controller.test.ts b/server/tests/unit/plugins/plugin-search.controller.test.ts new file mode 100644 index 000000000..01daa0c01 --- /dev/null +++ b/server/tests/unit/plugins/plugin-search.controller.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); + +import { PluginSearchController } from '../../../src/nest/plugins/contributions/plugin-search.controller'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; + +const hit = (over: Record = {}) => ({ id: 'a', name: 'Ristorante', lat: 45.4, lng: 9.2, ...over }); + +function controller(over: Partial = {}) { + const hooks = { + providersOf: vi.fn(() => ['p1']), + searchPlaces: vi.fn(async () => [hit()]), + ...over, + } as unknown as PluginHooks; + return { c: new PluginSearchController(hooks), hooks }; +} + +describe('PluginSearchController', () => { + beforeEach(() => { pluginsEnabled.mockReturnValue(true); }); + + it('returns [] when the runtime is disabled (no plugin calls)', async () => { + pluginsEnabled.mockReturnValue(false); + const { c, hooks } = controller(); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.providersOf).not.toHaveBeenCalled(); + }); + + it('returns [] without an authenticated user, and for a blank query', async () => { + const { c, hooks } = controller(); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(undefined))).toEqual({ places: [] }); + expect(await c.search(' ', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(await c.search(undefined, undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.searchPlaces).not.toHaveBeenCalled(); + }); + + it('asks no provider when none implements the hook', async () => { + const { c, hooks } = controller({ providersOf: vi.fn(() => []) }); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.searchPlaces).not.toHaveBeenCalled(); + }); + + it('passes the query, the bias and a capped limit, and drops a bias that is not a coordinate', async () => { + const { c, hooks } = controller(); + await c.search(' milan ', '45.4', '9.2', 'it', '999', req(5)); + expect(hooks.searchPlaces).toHaveBeenCalledWith( + 'p1', + { query: 'milan', limit: 20, lang: 'it', near: { lat: 45.4, lng: 9.2 } }, + 5, + ); + + await c.search('milan', '91', '9.2', undefined, undefined, req(5)); // latitude out of range + expect(hooks.searchPlaces).toHaveBeenLastCalledWith( + 'p1', + { query: 'milan', limit: 10, lang: undefined, near: undefined }, + 5, + ); + }); + + it('skips a provider that throws and keeps the other one (graceful)', async () => { + const { c } = controller({ + providersOf: vi.fn(() => ['slow', 'ok']), + searchPlaces: vi.fn(async (id: string) => { + if (id === 'slow') throw new Error('timed out'); + return [hit({ name: 'Trattoria' })]; + }) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places).toHaveLength(1); + expect(places[0].name).toBe('Trattoria'); + expect(places[0].pluginId).toBe('ok'); + }); + + it('interleaves two providers so neither one buries the other', async () => { + const { c } = controller({ + providersOf: vi.fn(() => ['a', 'b']), + searchPlaces: vi.fn(async (id: string) => + id === 'a' + ? [hit({ id: 'a1', name: 'A1' }), hit({ id: 'a2', name: 'A2' }), hit({ id: 'a3', name: 'A3' })] + : [hit({ id: 'b1', name: 'B1' })], + ) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places.map(p => p.name)).toEqual(['A1', 'B1', 'A2', 'A3']); + }); + + it('normalizes a hit into the shape the core search returns, and namespaces the id', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + hit({ address: 'Via Roma 1', rating: 4.5, website: 'https://ok.example', phone: '+39 02', category: 'restaurant', description: 'Good' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0]).toEqual({ + osm_id: 'plugin:p1:a', + name: 'Ristorante', + address: 'Via Roma 1', + lat: 45.4, + lng: 9.2, + rating: 4.5, + website: 'https://ok.example', + phone: '+39 02', + category: 'restaurant', + description: 'Good', + source: 'plugin:p1', + pluginId: 'p1', + }); + }); + + it('falls back to the coordinate when a hit carries no id of its own', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [{ name: 'Nameless index', lat: 45.4, lng: 9.2 }]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0].osm_id).toBe('plugin:p1:45.4,9.2'); + }); + + it('drops a hit with no name or no place on the earth', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + { name: '', lat: 45.4, lng: 9.2 }, + { name: 'No coords' }, + { name: 'Off the globe', lat: 91, lng: 9.2 }, + { name: 'Off the globe too', lat: 45.4, lng: 181 }, + 'not an object', + hit({ name: 'Keeper' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places.map(p => p.name)).toEqual(['Keeper']); + }); + + it('strips a javascript: website, clamps the rating and caps every string', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + hit({ id: 'x', name: 'n'.repeat(400), address: 'a'.repeat(600), website: 'javascript:alert(1)', rating: 9.7 }), + hit({ id: 'y', name: 'Low', rating: -3, website: 'http://plain.example' }), + hit({ id: 'z', name: 'Unrated', rating: 'not a number' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0].name).toHaveLength(200); + expect(places[0].address).toHaveLength(300); + expect(places[0].website).toBeNull(); + expect(places[0].rating).toBe(5); + expect(places[1].rating).toBe(0); + expect(places[1].website).toBe('http://plain.example'); + expect(places[2].rating).toBeNull(); + }); + + it('caps one provider at 20 hits', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => + Array.from({ length: 50 }, (_v, i) => hit({ id: `h${i}`, name: `H${i}` })), + ) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places).toHaveLength(20); + }); +}); 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/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index da1705e3c..b90baec46 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -378,6 +378,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'توفير الصور لميزة الذكريات', 'admin.plugins.perm.hook:calendar-source': 'توفير الأحداث للتقويم', 'admin.plugins.perm.hook:place-detail-provider': 'المساهمة بتفاصيل إضافية (مراجعات، تقييمات، روابط) لمكان ما', + 'admin.plugins.perm.hook:search-provider': 'الإجابة على عمليات البحث عن الأماكن من فهرس خاص به، إلى جانب نتائج TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'إظهار تحذيرات التحقق على الرحلة (تظهر في المخطط)', 'admin.plugins.perm.hook:table-contributor': 'إضافة أعمدة وإجراءات إلى عروض الرحلة (الحجوزات، الأماكن، الأيام)', 'admin.plugins.perm.hook:map-marker-provider': 'إضافة علامات إلى خريطة الرحلة (مثل عرض الحجوزات أو نقاط الاهتمام)', @@ -529,6 +530,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'يوفّر الصور', 'admin.plugins.cap.calendar': 'يوفّر أحداث التقويم', 'admin.plugins.cap.placeDetails': 'يُثري الأماكن', + 'admin.plugins.cap.search': 'يجيب على عمليات البحث', 'admin.plugins.cap.warnings': 'يرصد المشكلات', 'admin.plugins.cap.mapLayers': 'يرسم على الخريطة', 'admin.plugins.cap.routing': 'يوفّر التوجيه', 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/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 6fc2908ec..78b99dad4 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -18,17 +18,74 @@ 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': 'المدى بخزان واحد', + 'roadtrip.limit.fillLabel': 'التعبئة حتى', + 'roadtrip.limit.fillFull': 'ممتلئ', + 'roadtrip.limit.fillBadge': 'يملأ حتى {percent} ٪', + 'roadtrip.limit.wearNote': '{percent} ٪ مفقودة بسبب التقادم', + 'roadtrip.limit.rangeLabelCharge': 'المدى لكل شحنة', + 'roadtrip.limit.sectionTrip': 'هذه الرحلة', + 'roadtrip.limit.sectionDriving': 'القيادة', + 'roadtrip.limit.sectionVehicle': 'المركبة', + 'roadtrip.limit.tankLabel': 'سعة الخزان', + 'roadtrip.limit.batteryLabel': 'البطارية', + 'roadtrip.limit.useLabel': 'الاستهلاك', + 'roadtrip.limit.wearLabel': 'تآكل البطارية', + 'roadtrip.limit.computed': 'محسوب من بيانات المركبة', + 'roadtrip.limit.perFill': 'لكل خزان', + 'roadtrip.limit.perCharge': 'لكل شحنة', + 'roadtrip.limit.afterFill': 'توقف حتى {percent} ٪: {distance}', + 'roadtrip.limit.fullNote': 'التوقف يملأ بالكامل', + 'roadtrip.limit.rangeEmpty': 'لا يوجد مدى', + 'roadtrip.limit.rangeEmptyHint': 'أدخل المدى، أو املأ الحقول ليحسبه TREK.', + 'roadtrip.limit.specHint': 'عند تعبئتها، تحدد هذه الحقول المدى أعلاه.', + 'roadtrip.limit.vehicleHint': 'حدد نوع سيارتك لتُدخل بياناتها بدل المدى.', 'roadtrip.limit.minutes': 'دقيقة', 'roadtrip.limit.off': 'معطل', + 'roadtrip.limit.blockNote': 'كتلة واحدة = {distance}', + 'roadtrip.limit.echoOver': '{over} من {days} أيام تتجاوز حدّك.', + 'roadtrip.limit.echoNone': 'كل الأيام {days} ضمن حدودك.', + 'roadtrip.limit.echoDry': '{count} مقاطع بلا محطة يمكن بلوغها.', + 'roadtrip.limit.echoEmpty': 'افتح رحلة برية لترى الأثر.', + 'roadtrip.limit.specToggle': 'احسبها من بيانات السيارة', 'roadtrip.limit.hint': 'يحسب وقت القيادة فقط. لا يشمل التوقفات والمبيت.', 'roadtrip.limit.legOver': '{time} فوق أطول قيادة لديك', 'roadtrip.limit.dayOver': '{time} زيادة', 'roadtrip.limit.range': '{distance} منذ آخر تعبئة', 'roadtrip.limit.none': 'غير محددة', + 'roadtrip.line.section': 'خط المسار', + 'roadtrip.line.connect': 'ربط الأيام', + 'roadtrip.line.dayColors': 'لون لكل يوم', + 'roadtrip.line.hint': 'يحسب أيضًا المسافة بين يومين ويحتسبها على يوم الوصول.', + '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.refuel.dry': 'ينفد الخزان هنا', + 'roadtrip.refuel.after': 'بعد {distance}', + 'roadtrip.limit.vehicleLabel': 'بماذا تقود', + 'roadtrip.limit.vehicleAny': 'كلاهما', + 'roadtrip.limit.vehicleCombustion': 'وقود', + 'roadtrip.limit.vehicleElectric': 'كهرباء', + 'roadtrip.refuel.dryElectric': 'تنفد البطارية هنا', + 'roadtrip.refuel.findElectric': 'البحث عن شحن', + 'roadtrip.refuel.find': 'ابحث عن وقود', + 'roadtrip.refuel.again': 'حاول مجددًا', + 'roadtrip.refuel.looking': 'جارٍ البحث على طول الطريق…', + 'roadtrip.refuel.spare': 'يتبقى {distance}', + 'roadtrip.refuel.add': 'إضافة {name} كمحطة تزود بالوقود', + 'roadtrip.refuel.addElectric': 'إضافة {name} كمحطة شحن', + 'roadtrip.refuel.none': 'لم يُعثر على شيء في المسافة التي يمكن بلوغها.', + 'roadtrip.refuel.incomplete': 'اقتُطع البحث، لذا لم تُفحص هذه المسافة بالكامل.', + 'roadtrip.refuel.failed': 'لم يستجب البحث عن الأماكن.', 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'حتى الآن {name} فقط — أفلِت محطة هنا', 'roadtrip.leg.driveText': '{distance} خلال {time}', 'roadtrip.day.stopCount': '{count} محطات', + 'roadtrip.day.fold': 'طيّ', + 'roadtrip.day.folded': 'مطوي', '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': 'نوع المحطة', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة', 'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد', 'roadtrip.warn.overnight': 'اليوم التالي', + 'roadtrip.spill.title': 'من اليوم {number}', + 'roadtrip.spill.departs': 'المغادرة {time}', 'roadtrip.summary.stops': 'محطات التوقف', 'roadtrip.stay.add': 'تحديد مدة التوقف', 'roadtrip.stay.none': 'بدون توقف', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/br/admin.ts index 8bd11110c..edfb67689 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -361,6 +361,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Fornecer eventos para o calendário', 'admin.plugins.perm.hook:place-detail-provider': 'Contribuir com detalhes extras (avaliações, notas, links) para um local', + 'admin.plugins.perm.hook:search-provider': + 'Responder buscas de lugares a partir de um índice próprio, ao lado dos resultados do TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Emitir avisos de validação em uma viagem (exibidos no planejador)', 'admin.plugins.perm.hook:table-contributor': 'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)', @@ -518,6 +520,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fornece fotos', 'admin.plugins.cap.calendar': 'Fornece eventos de calendário', 'admin.plugins.cap.placeDetails': 'Enriquece locais', + 'admin.plugins.cap.search': 'Responde buscas', 'admin.plugins.cap.warnings': 'Sinaliza problemas', 'admin.plugins.cap.mapLayers': 'Desenha no mapa', 'admin.plugins.cap.routing': 'Oferece roteamento', 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/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 370d387eb..23d42d19d 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Abastecer até', + 'roadtrip.limit.fillFull': 'cheio', + 'roadtrip.limit.fillBadge': 'abastece até {percent} %', + 'roadtrip.limit.wearNote': '{percent} % perdidos pelo desgaste', + 'roadtrip.limit.rangeLabelCharge': 'Autonomia por carga', + 'roadtrip.limit.sectionTrip': 'Esta viagem', + 'roadtrip.limit.sectionDriving': 'Condução', + 'roadtrip.limit.sectionVehicle': 'Veículo', + 'roadtrip.limit.tankLabel': 'Tanque', + 'roadtrip.limit.batteryLabel': 'Bateria', + 'roadtrip.limit.useLabel': 'Consumo', + 'roadtrip.limit.wearLabel': 'Desgaste da bateria', + 'roadtrip.limit.computed': 'Calculado a partir dos dados do veículo', + 'roadtrip.limit.perFill': 'por tanque', + 'roadtrip.limit.perCharge': 'por carga', + 'roadtrip.limit.afterFill': 'Uma parada até {percent} % dá {distance}', + 'roadtrip.limit.fullNote': 'Uma parada enche por completo', + 'roadtrip.limit.rangeEmpty': 'Sem autonomia', + 'roadtrip.limit.rangeEmptyHint': 'Digite uma autonomia, ou preencha os campos e o TREK a calcula.', + 'roadtrip.limit.specHint': 'Preenchidos, estes campos definem a autonomia acima.', + 'roadtrip.limit.vehicleHint': 'Diga o que você dirige e poderá informar os dados em vez da autonomia.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'desligado', + 'roadtrip.limit.blockNote': 'Um bloco = {distance}', + 'roadtrip.limit.echoOver': '{over} de {days} dias acima do seu limite.', + 'roadtrip.limit.echoNone': 'Todos os {days} dias dentro dos limites.', + 'roadtrip.limit.echoDry': '{count} trechos sem parada alcançável.', + 'roadtrip.limit.echoEmpty': 'Abra uma viagem para ver o efeito.', + 'roadtrip.limit.specToggle': 'Calcular pelos dados do carro', 'roadtrip.limit.hint': 'Conta apenas o tempo de condução. Paradas e pernoites não estão incluídos.', 'roadtrip.limit.legOver': '{time} acima do seu trecho máximo', 'roadtrip.limit.dayOver': '{time} a mais', 'roadtrip.limit.range': '{distance} desde o último abastecimento', 'roadtrip.limit.none': 'Nenhum definido', + 'roadtrip.line.section': 'Linha da rota', + 'roadtrip.line.connect': 'Conectar os dias', + 'roadtrip.line.dayColors': 'Uma cor por dia', + 'roadtrip.line.hint': 'Também calcula o trecho entre dois dias e conta no dia de chegada.', + '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.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.dryElectric': 'A bateria acaba aqui', + 'roadtrip.refuel.findElectric': 'Procurar recarga', + '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', + 'roadtrip.refuel.addElectric': 'Adicionar {name} como parada de recarga', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Até agora só {name} — solte uma parada aqui', 'roadtrip.leg.driveText': '{distance} em {time}', 'roadtrip.day.stopCount': '{count} paradas', + 'roadtrip.day.fold': 'Recolher', + 'roadtrip.day.folded': 'Recolhido', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover', 'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido', 'roadtrip.warn.overnight': 'Dia seguinte', + 'roadtrip.spill.title': 'Do dia {number}', + 'roadtrip.spill.departs': 'saída {time}', 'roadtrip.summary.stops': 'Paradas', 'roadtrip.stay.add': 'Definir a permanencia', 'roadtrip.stay.none': 'Sem permanencia', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/ca/admin.ts index f1f6a72bd..85a74f185 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -522,6 +522,8 @@ const admin: TranslationStrings = { 'admin.plugins.requiresTrek': 'Requereix TREK {version}+', 'admin.plugins.reviewedOn': 'Verificat el {date}', 'admin.plugins.perm.hook:map-layer-provider': 'Dibuixar rutes, corredors i zones al mapa del viatge', + 'admin.plugins.perm.hook:search-provider': + "Respondre cerques de llocs des d'un índex propi, al costat dels resultats de TREK", 'admin.plugins.perm.hook:route-provider': 'Oferir perfils de ruta amb què el planificador pot calcular els dies (p. ex. rutes per a cotxe elèctric amb parades de recàrrega)', 'admin.plugins.perm.hook:day-schedule-provider': @@ -654,6 +656,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Proporciona fotos', 'admin.plugins.cap.calendar': 'Proporciona esdeveniments de calendari', 'admin.plugins.cap.placeDetails': 'Enriqueix els llocs', + 'admin.plugins.cap.search': 'Respon cerques', 'admin.plugins.cap.warnings': 'Marca problemes', 'admin.plugins.cap.mapLayers': 'Dibuixa al mapa', 'admin.plugins.cap.routing': 'Calcula rutes', 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/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 324bdcdb7..c4e4880c9 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Omple fins a', + 'roadtrip.limit.fillFull': 'ple', + 'roadtrip.limit.fillBadge': 'omple al {percent} %', + 'roadtrip.limit.wearNote': '{percent} % perdut per l’edat', + 'roadtrip.limit.rangeLabelCharge': 'Autonomia per càrrega', + 'roadtrip.limit.sectionTrip': 'Aquest viatge', + 'roadtrip.limit.sectionDriving': 'Conducció', + 'roadtrip.limit.sectionVehicle': 'Vehicle', + 'roadtrip.limit.tankLabel': 'Dipòsit', + 'roadtrip.limit.batteryLabel': 'Bateria', + 'roadtrip.limit.useLabel': 'Consum', + 'roadtrip.limit.wearLabel': 'Desgast bateria', + 'roadtrip.limit.computed': 'Calculat a partir de les dades del vehicle', + 'roadtrip.limit.perFill': 'per dipòsit', + 'roadtrip.limit.perCharge': 'per càrrega', + 'roadtrip.limit.afterFill': 'Una parada al {percent} % dóna {distance}', + 'roadtrip.limit.fullNote': 'Una parada omple del tot', + 'roadtrip.limit.rangeEmpty': 'Sense autonomia', + 'roadtrip.limit.rangeEmptyHint': 'Escriu una autonomia, o omple els camps i TREK la calcula.', + 'roadtrip.limit.specHint': 'Omplerts, aquests camps determinen l’autonomia de dalt.', + 'roadtrip.limit.vehicleHint': 'Digues què condueixes i podràs donar-ne les dades en lloc de l’autonomia.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'desactivat', + 'roadtrip.limit.blockNote': 'Un bloc = {distance}', + 'roadtrip.limit.echoOver': '{over} de {days} dies superen el teu límit.', + 'roadtrip.limit.echoNone': 'Els {days} dies són dins dels límits.', + 'roadtrip.limit.echoDry': '{count} trams sense parada assolible.', + 'roadtrip.limit.echoEmpty': 'Obre una ruta per veure’n l’efecte.', + 'roadtrip.limit.specToggle': 'Calcula-ho a partir del cotxe', 'roadtrip.limit.hint': 'Només compta el temps de conducció. Les parades i les nits no hi són incloses.', 'roadtrip.limit.legOver': '{time} per sobre del teu trajecte màxim', 'roadtrip.limit.dayOver': '{time} de més', 'roadtrip.limit.range': '{distance} des de l’última càrrega', 'roadtrip.limit.none': 'Cap definit', + 'roadtrip.line.section': 'Línia de la ruta', + 'roadtrip.line.connect': 'Connecta els dies', + 'roadtrip.line.dayColors': 'Un color per dia', + 'roadtrip.line.hint': 'També calcula el tram entre dos dies i el compta al dia d’arribada.', + '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.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.dryElectric': 'Aquí s’acaba la bateria', + 'roadtrip.refuel.findElectric': 'Cerca un punt de recàrrega', + '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', + 'roadtrip.refuel.addElectric': 'Afegeix {name} com a parada de recàrrega', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'De moment només {name}: deixa una parada aquí', 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} parades', + 'roadtrip.day.fold': 'Plega', + 'roadtrip.day.folded': 'Plegat', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar', 'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada', 'roadtrip.warn.overnight': 'Dia següent', + 'roadtrip.spill.title': 'Del dia {number}', + 'roadtrip.spill.departs': 'surt {time}', 'roadtrip.summary.stops': 'Parades', 'roadtrip.stay.add': 'Defineix una estada', 'roadtrip.stay.none': 'Sense estada', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/cs/admin.ts index 1b099893d..203e98f95 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -364,6 +364,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Poskytovat fotky do sekce Fotky', 'admin.plugins.perm.hook:calendar-source': 'Poskytovat události do kalendáře', 'admin.plugins.perm.hook:place-detail-provider': 'Přidávat další podrobnosti (recenze, hodnocení, odkazy) k místu', + 'admin.plugins.perm.hook:search-provider': 'Odpovídat na vyhledávání míst z vlastního indexu, vedle výsledků TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Vyvolávat ověřovací upozornění u cesty (zobrazená v plánovači)', 'admin.plugins.perm.hook:table-contributor': 'Přidávat sloupce a akce do zobrazení cesty (rezervace, místa, dny)', 'admin.plugins.perm.hook:map-marker-provider': 'Přidávat značky na mapu cesty (např. zobrazit rezervace nebo POI)', @@ -516,6 +517,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Poskytuje fotky', 'admin.plugins.cap.calendar': 'Poskytuje události kalendáře', 'admin.plugins.cap.placeDetails': 'Obohacuje místa', + 'admin.plugins.cap.search': 'Odpovídá na vyhledávání', 'admin.plugins.cap.warnings': 'Označuje problémy', 'admin.plugins.cap.mapLayers': 'Kreslí na mapě', 'admin.plugins.cap.routing': 'Nabízí trasování', 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/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index cc0645423..8f082dc43 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -18,17 +18,74 @@ 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ž', + 'roadtrip.limit.fillLabel': 'Natankovat na', + 'roadtrip.limit.fillFull': 'plná', + 'roadtrip.limit.fillBadge': 'tankuje na {percent} %', + 'roadtrip.limit.wearNote': '{percent} % ubylo stárnutím', + 'roadtrip.limit.rangeLabelCharge': 'Dojezd na nabití', + 'roadtrip.limit.sectionTrip': 'Tato cesta', + 'roadtrip.limit.sectionDriving': 'Jízda', + 'roadtrip.limit.sectionVehicle': 'Vozidlo', + 'roadtrip.limit.tankLabel': 'Objem nádrže', + 'roadtrip.limit.batteryLabel': 'Baterie', + 'roadtrip.limit.useLabel': 'Spotřeba', + 'roadtrip.limit.wearLabel': 'Opotřebení baterie', + 'roadtrip.limit.computed': 'Vypočteno z údajů vozidla', + 'roadtrip.limit.perFill': 'na nádrž', + 'roadtrip.limit.perCharge': 'na nabití', + 'roadtrip.limit.afterFill': 'Zastávka na {percent} % dá {distance}', + 'roadtrip.limit.fullNote': 'Zastávka doplní do plna', + 'roadtrip.limit.rangeEmpty': 'Bez dojezdu', + 'roadtrip.limit.rangeEmptyHint': 'Zadej dojezd, nebo vyplň pole a TREK ho spočítá.', + 'roadtrip.limit.specHint': 'Vyplněná pole určují dojezd nahoře.', + 'roadtrip.limit.vehicleHint': 'Uveď, čím jezdíš, a místo dojezdu můžeš zadat údaje vozidla.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'vypnuto', + 'roadtrip.limit.blockNote': 'Jeden blok = {distance}', + 'roadtrip.limit.echoOver': '{over} z {days} dnů nad tvým limitem.', + 'roadtrip.limit.echoNone': 'Všech {days} dnů je v limitech.', + 'roadtrip.limit.echoDry': '{count} úseků bez dosažitelné zastávky.', + 'roadtrip.limit.echoEmpty': 'Otevři road trip a uvidíš efekt.', + 'roadtrip.limit.specToggle': 'Spočítat z údajů auta', 'roadtrip.limit.hint': 'Počítá jen čas jízdy. Zastávky a noclehy nejsou zahrnuty.', 'roadtrip.limit.legOver': 'o {time} déle než vaše nejdelší jízda', 'roadtrip.limit.dayOver': 'o {time} více', 'roadtrip.limit.range': '{distance} od posledního tankování', 'roadtrip.limit.none': 'Nenastaveno', + 'roadtrip.line.section': 'Čára trasy', + 'roadtrip.line.connect': 'Propojit dny', + 'roadtrip.line.dayColors': 'Barva na den', + 'roadtrip.line.hint': 'Spočítá i úsek mezi dvěma dny a připočte ho ke dni příjezdu.', + '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.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.dryElectric': 'Tady dojde baterie', + 'roadtrip.refuel.findElectric': 'Najít nabíječku', + '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', + 'roadtrip.refuel.addElectric': 'Přidat {name} jako zastávku na nabíjení', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Zatím jen {name} — přetáhněte sem zastávku', 'roadtrip.leg.driveText': '{distance} za {time}', 'roadtrip.day.stopCount': 'zastávek: {count}', + 'roadtrip.day.fold': 'Sbalit', + 'roadtrip.day.folded': 'Sbaleno', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete', 'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase', 'roadtrip.warn.overnight': 'Následující den', + 'roadtrip.spill.title': 'Ze dne {number}', + 'roadtrip.spill.departs': 'odjezd {time}', 'roadtrip.summary.stops': 'Zastávky', 'roadtrip.stay.add': 'Nastavit dobu zastavky', 'roadtrip.stay.none': 'Bez zastavky', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/de/admin.ts index 67a2d541c..69e286aee 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -367,6 +367,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Termine für den Kalender bereitstellen', 'admin.plugins.perm.hook:place-detail-provider': 'Zusatzdetails (Bewertungen, Ratings, Links) zu einem Ort beisteuern', + 'admin.plugins.perm.hook:search-provider': + 'Ortssuchen aus einem eigenen Index beantworten, neben TREKs eigenen Ergebnissen', 'admin.plugins.perm.hook:trip-warning-provider': 'Validierungswarnungen zu einer Reise ausgeben (im Planer angezeigt)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +527,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Liefert Fotos', 'admin.plugins.cap.calendar': 'Liefert Kalender-Events', 'admin.plugins.cap.placeDetails': 'Reichert Orte an', + 'admin.plugins.cap.search': 'Beantwortet Suchen', 'admin.plugins.cap.warnings': 'Meldet Probleme', 'admin.plugins.cap.mapLayers': 'Zeichnet auf der Karte', 'admin.plugins.cap.routing': 'Bietet Routing an', 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/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 8245a438e..4260d5cfd 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Tanken bis', + 'roadtrip.limit.fillFull': 'voll', + 'roadtrip.limit.fillBadge': 'tankt auf {percent} %', + 'roadtrip.limit.wearNote': '{percent} % durch Alterung weg', + 'roadtrip.limit.rangeLabelCharge': 'Reichweite pro Ladung', + 'roadtrip.limit.sectionTrip': 'Diese Reise', + 'roadtrip.limit.sectionDriving': 'Fahren', + 'roadtrip.limit.sectionVehicle': 'Fahrzeug', + 'roadtrip.limit.tankLabel': 'Tankinhalt', + 'roadtrip.limit.batteryLabel': 'Akkukapazität', + 'roadtrip.limit.useLabel': 'Verbrauch', + 'roadtrip.limit.wearLabel': 'Akku-Alterung', + 'roadtrip.limit.computed': 'Aus den Fahrzeugdaten berechnet', + 'roadtrip.limit.perFill': 'pro Tankfüllung', + 'roadtrip.limit.perCharge': 'pro Ladung', + 'roadtrip.limit.afterFill': 'Ein Stopp auf {percent} % bringt {distance}', + 'roadtrip.limit.fullNote': 'Ein Stopp macht wieder voll', + 'roadtrip.limit.rangeEmpty': 'Keine Reichweite', + 'roadtrip.limit.rangeEmptyHint': 'Trag eine Reichweite ein oder fülle die Felder aus, dann rechnet TREK sie aus.', + 'roadtrip.limit.specHint': 'Ausgefüllt bestimmen diese Werte die Reichweite oben.', + 'roadtrip.limit.vehicleHint': 'Sag, was du fährst, dann kannst du statt der Reichweite die Fahrzeugdaten angeben.', 'roadtrip.limit.minutes': 'Min', 'roadtrip.limit.off': 'aus', + 'roadtrip.limit.blockNote': 'Ein Block = {distance}', + 'roadtrip.limit.echoOver': '{over} von {days} Tagen über deinem Limit.', + 'roadtrip.limit.echoNone': 'Alle {days} Tage innerhalb deiner Grenzen.', + 'roadtrip.limit.echoDry': '{count} Abschnitte ohne erreichbaren Stopp.', + 'roadtrip.limit.echoEmpty': 'Öffne einen Roadtrip, um zu sehen, was das bewirkt.', + 'roadtrip.limit.specToggle': 'Aus den Fahrzeugdaten rechnen', 'roadtrip.limit.hint': 'Zählt nur Fahrzeit. Standzeiten und Übernachtungen sind nicht enthalten.', 'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt', 'roadtrip.limit.dayOver': '{time} drüber', 'roadtrip.limit.range': '{distance} seit dem letzten Tanken', 'roadtrip.limit.none': 'Keine gesetzt', + 'roadtrip.line.section': 'Routenlinie', + 'roadtrip.line.connect': 'Tage verbinden', + 'roadtrip.line.dayColors': 'Farbe je Tag', + 'roadtrip.line.hint': 'Routet auch die Strecke zwischen zwei Tagen und zählt sie zum Ankunftstag.', + '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.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.dryElectric': 'Batterie ist hier leer', + 'roadtrip.refuel.findElectric': 'Ladesäule suchen', + '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', + 'roadtrip.refuel.addElectric': '{name} als Ladestopp hinzufügen', + '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', @@ -38,9 +95,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 +135,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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Bisher nur {name} — hier einen Stopp ablegen', 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} Stopps', + 'roadtrip.day.fold': 'Zuklappen', + 'roadtrip.day.folded': 'Zugeklappt', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt', 'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit', 'roadtrip.warn.overnight': 'Folgetag', + 'roadtrip.spill.title': 'Von Tag {number}', + 'roadtrip.spill.departs': 'ab {time}', 'roadtrip.summary.stops': 'Stopps', 'roadtrip.stay.add': 'Aufenthalt festlegen', 'roadtrip.stay.none': 'Kein Aufenthalt', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/en/admin.ts index ea91759a7..ba4da1ac4 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -410,6 +410,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Provide photos to Memories', 'admin.plugins.perm.hook:calendar-source': 'Provide events to the calendar', 'admin.plugins.perm.hook:place-detail-provider': 'Contribute extra details (reviews, ratings, links) to a place', + 'admin.plugins.perm.hook:search-provider': "Answer place searches from its own index, beside TREK's own results", 'admin.plugins.perm.hook:trip-warning-provider': 'Raise validation warnings on a trip (shown in the planner)', 'admin.plugins.perm.hook:table-contributor': 'Add columns and actions to trip views (reservations, places, days)', 'admin.plugins.perm.hook:map-marker-provider': 'Add markers to the trip map (e.g. show bookings or POIs)', @@ -563,6 +564,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Provides photos', 'admin.plugins.cap.calendar': 'Provides calendar events', 'admin.plugins.cap.placeDetails': 'Enriches places', + 'admin.plugins.cap.search': 'Answers searches', 'admin.plugins.cap.warnings': 'Flags issues', 'admin.plugins.cap.mapLayers': 'Draws on the map', 'admin.plugins.cap.routing': 'Offers routing', 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/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 572426844..e49283bba 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Fill up to', + 'roadtrip.limit.fillFull': 'full', + 'roadtrip.limit.fillBadge': 'fills to {percent} %', + 'roadtrip.limit.wearNote': '{percent} % lost to age', + 'roadtrip.limit.rangeLabelCharge': 'Range on one charge', + 'roadtrip.limit.sectionTrip': 'This trip', + 'roadtrip.limit.sectionDriving': 'Driving', + 'roadtrip.limit.sectionVehicle': 'Vehicle', + 'roadtrip.limit.tankLabel': 'Tank size', + 'roadtrip.limit.batteryLabel': 'Battery', + 'roadtrip.limit.useLabel': 'Consumption', + 'roadtrip.limit.wearLabel': 'Battery wear', + 'roadtrip.limit.computed': 'Worked out from the vehicle’s figures', + 'roadtrip.limit.perFill': 'per tank', + 'roadtrip.limit.perCharge': 'per charge', + 'roadtrip.limit.afterFill': 'A {percent} % stop gives {distance}', + 'roadtrip.limit.fullNote': 'A stop fills right up', + 'roadtrip.limit.rangeEmpty': 'No range set', + 'roadtrip.limit.rangeEmptyHint': 'Type a range, or fill in the figures below and TREK works it out.', + 'roadtrip.limit.specHint': 'Filled in, these decide the range above.', + 'roadtrip.limit.vehicleHint': 'Name the kind of car and you can state its figures instead of a range.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'off', + 'roadtrip.limit.blockNote': 'One block = {distance}', + 'roadtrip.limit.echoOver': '{over} of {days} days over your limit.', + 'roadtrip.limit.echoNone': 'All {days} days within your limits.', + 'roadtrip.limit.echoDry': '{count} stretches with no reachable stop.', + 'roadtrip.limit.echoEmpty': 'Open a road trip to see what these do to it.', + 'roadtrip.limit.specToggle': 'Work it out from the car', 'roadtrip.limit.hint': 'Counts driving time only. Time spent at stops and overnight legs are not included.', 'roadtrip.limit.legOver': '{time} over your longest drive', 'roadtrip.limit.dayOver': '{time} over', 'roadtrip.limit.range': '{distance} since the last fill-up', 'roadtrip.limit.none': 'None set', + 'roadtrip.line.section': 'Route line', + 'roadtrip.line.connect': 'Connect the days', + 'roadtrip.line.dayColors': 'A colour per day', + 'roadtrip.line.hint': 'Also routes the gap between two days, and counts it on the day it arrives.', + '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.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.dryElectric': 'Battery runs out here', + 'roadtrip.refuel.findElectric': 'Find charging', + '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', + 'roadtrip.refuel.addElectric': 'Add {name} as a charging 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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Only {name} so far — drop a stop here', 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} stops', + 'roadtrip.day.fold': 'Fold', + 'roadtrip.day.folded': 'Folded', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove', 'roadtrip.warn.late': 'Arrives {minutes} min after the time you set', 'roadtrip.warn.overnight': 'Next day', + 'roadtrip.spill.title': 'From day {number}', + 'roadtrip.spill.departs': 'leaves {time}', 'roadtrip.summary.stops': 'Stops', 'roadtrip.stay.add': 'Add a stay', 'roadtrip.stay.none': 'No stay', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/es/admin.ts index 8ab4f1af7..6d3f41745 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -370,6 +370,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Proporcionar eventos al calendario', 'admin.plugins.perm.hook:place-detail-provider': 'Aportar detalles adicionales (reseñas, valoraciones, enlaces) a un lugar', + 'admin.plugins.perm.hook:search-provider': + 'Responder a búsquedas de lugares desde su propio índice, junto a los resultados de TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Generar advertencias de validación en un viaje (mostradas en el planificador)', 'admin.plugins.perm.hook:table-contributor': @@ -527,6 +529,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Proporciona fotos', 'admin.plugins.cap.calendar': 'Proporciona eventos de calendario', 'admin.plugins.cap.placeDetails': 'Enriquece lugares', + 'admin.plugins.cap.search': 'Responde búsquedas', 'admin.plugins.cap.warnings': 'Marca problemas', 'admin.plugins.cap.mapLayers': 'Dibuja en el mapa', 'admin.plugins.cap.routing': 'Ofrece rutas', 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/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index f19d07bef..393624e18 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Repostar hasta', + 'roadtrip.limit.fillFull': 'lleno', + 'roadtrip.limit.fillBadge': 'reposta al {percent} %', + 'roadtrip.limit.wearNote': '{percent} % perdido por el desgaste', + 'roadtrip.limit.rangeLabelCharge': 'Autonomía por carga', + 'roadtrip.limit.sectionTrip': 'Este viaje', + 'roadtrip.limit.sectionDriving': 'Conducción', + 'roadtrip.limit.sectionVehicle': 'Vehículo', + 'roadtrip.limit.tankLabel': 'Depósito', + 'roadtrip.limit.batteryLabel': 'Batería', + 'roadtrip.limit.useLabel': 'Consumo', + 'roadtrip.limit.wearLabel': 'Desgaste batería', + 'roadtrip.limit.computed': 'Calculado con los datos del vehículo', + 'roadtrip.limit.perFill': 'por depósito', + 'roadtrip.limit.perCharge': 'por carga', + 'roadtrip.limit.afterFill': 'Una parada al {percent} % da {distance}', + 'roadtrip.limit.fullNote': 'Una parada llena del todo', + 'roadtrip.limit.rangeEmpty': 'Sin autonomía', + 'roadtrip.limit.rangeEmptyHint': 'Escribe una autonomía, o rellena los campos y TREK la calcula.', + 'roadtrip.limit.specHint': 'Rellenos, estos campos deciden la autonomía de arriba.', + 'roadtrip.limit.vehicleHint': 'Indica el tipo de vehículo y podrás dar sus datos en vez de la autonomía.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'desactivado', + 'roadtrip.limit.blockNote': 'Un bloque = {distance}', + 'roadtrip.limit.echoOver': '{over} de {days} días superan tu límite.', + 'roadtrip.limit.echoNone': 'Los {days} días están dentro de tus límites.', + 'roadtrip.limit.echoDry': '{count} tramos sin parada alcanzable.', + 'roadtrip.limit.echoEmpty': 'Abre una ruta para ver el efecto.', + 'roadtrip.limit.specToggle': 'Calcular a partir del coche', 'roadtrip.limit.hint': 'Solo cuenta el tiempo de conducción. Las paradas y las noches no se incluyen.', 'roadtrip.limit.legOver': '{time} más que tu trayecto máximo', 'roadtrip.limit.dayOver': '{time} de más', 'roadtrip.limit.range': '{distance} desde el último repostaje', 'roadtrip.limit.none': 'Ninguno definido', + 'roadtrip.line.section': 'Línea de la ruta', + 'roadtrip.line.connect': 'Conectar los días', + 'roadtrip.line.dayColors': 'Un color por día', + 'roadtrip.line.hint': 'También calcula el tramo entre dos días y lo cuenta en el día de llegada.', + '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.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.dryElectric': 'La batería se agota aquí', + 'roadtrip.refuel.findElectric': 'Buscar recarga', + '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', + 'roadtrip.refuel.addElectric': 'Añadir {name} como parada de recarga', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Por ahora solo {name}: suelta una parada aquí', 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} paradas', + 'roadtrip.day.fold': 'Plegar', + 'roadtrip.day.folded': 'Plegado', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar', 'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada', 'roadtrip.warn.overnight': 'Día siguiente', + 'roadtrip.spill.title': 'Del día {number}', + 'roadtrip.spill.departs': 'sale {time}', 'roadtrip.summary.stops': 'Paradas', 'roadtrip.stay.add': 'Anadir una parada', 'roadtrip.stay.none': 'Sin parada', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/fr/admin.ts index cfa526abe..03e9247e9 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -370,6 +370,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Fournir des photos à Photos', 'admin.plugins.perm.hook:calendar-source': 'Fournir des événements au calendrier', 'admin.plugins.perm.hook:place-detail-provider': 'Ajouter des détails supplémentaires (avis, notes, liens) à un lieu', + 'admin.plugins.perm.hook:search-provider': + 'Répondre aux recherches de lieux depuis son propre index, à côté des résultats de TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Émettre des avertissements de validation sur un voyage (affichés dans le planificateur)', 'admin.plugins.perm.hook:table-contributor': @@ -530,6 +532,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fournit des photos', 'admin.plugins.cap.calendar': 'Fournit des événements de calendrier', 'admin.plugins.cap.placeDetails': 'Enrichit les lieux', + 'admin.plugins.cap.search': 'Répond aux recherches', 'admin.plugins.cap.warnings': 'Signale les problèmes', 'admin.plugins.cap.mapLayers': 'Dessine sur la carte', 'admin.plugins.cap.routing': 'Propose des itinéraires', 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/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index f757d68cf..56e401811 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Remplir jusqu’à', + 'roadtrip.limit.fillFull': 'plein', + 'roadtrip.limit.fillBadge': 'remplit à {percent} %', + 'roadtrip.limit.wearNote': '{percent} % perdus avec l’âge', + 'roadtrip.limit.rangeLabelCharge': 'Autonomie par charge', + 'roadtrip.limit.sectionTrip': 'Ce voyage', + 'roadtrip.limit.sectionDriving': 'Conduite', + 'roadtrip.limit.sectionVehicle': 'Véhicule', + 'roadtrip.limit.tankLabel': 'Réservoir', + 'roadtrip.limit.batteryLabel': 'Batterie', + 'roadtrip.limit.useLabel': 'Consommation', + 'roadtrip.limit.wearLabel': 'Usure batterie', + 'roadtrip.limit.computed': 'Calculé à partir des données du véhicule', + 'roadtrip.limit.perFill': 'par plein', + 'roadtrip.limit.perCharge': 'par charge', + 'roadtrip.limit.afterFill': 'Un arrêt à {percent} % donne {distance}', + 'roadtrip.limit.fullNote': 'Un arrêt fait le plein', + 'roadtrip.limit.rangeEmpty': 'Aucune autonomie', + 'roadtrip.limit.rangeEmptyHint': 'Saisissez une autonomie, ou remplissez les champs et TREK la calcule.', + 'roadtrip.limit.specHint': 'Remplis, ces champs déterminent l’autonomie ci-dessus.', + 'roadtrip.limit.vehicleHint': 'Indiquez le type de véhicule pour saisir ses données au lieu d’une autonomie.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'désactivé', + 'roadtrip.limit.blockNote': 'Un bloc = {distance}', + 'roadtrip.limit.echoOver': '{over} jours sur {days} dépassent ta limite.', + 'roadtrip.limit.echoNone': 'Les {days} jours respectent tes limites.', + 'roadtrip.limit.echoDry': '{count} tronçons sans arrêt accessible.', + 'roadtrip.limit.echoEmpty': 'Ouvre un road trip pour voir l’effet.', + 'roadtrip.limit.specToggle': 'Calculer d’après la voiture', 'roadtrip.limit.hint': 'Compte uniquement le temps de conduite. Les arrêts et les nuits ne sont pas inclus.', 'roadtrip.limit.legOver': '{time} de plus que votre trajet maximal', 'roadtrip.limit.dayOver': '{time} de trop', 'roadtrip.limit.range': '{distance} depuis le dernier plein', 'roadtrip.limit.none': 'Aucune définie', + 'roadtrip.line.section': 'Ligne de l’itinéraire', + 'roadtrip.line.connect': 'Relier les jours', + 'roadtrip.line.dayColors': 'Une couleur par jour', + 'roadtrip.line.hint': 'Calcule aussi le trajet entre deux jours et le compte au jour d’arrivée.', + '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.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.dryElectric': 'La batterie est vide ici', + 'roadtrip.refuel.findElectric': 'Chercher une borne', + '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', + 'roadtrip.refuel.addElectric': 'Ajouter {name} comme arrêt de recharge', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Seulement {name} pour l’instant — déposez un arrêt ici', 'roadtrip.leg.driveText': '{distance} en {time}', 'roadtrip.day.stopCount': '{count} arrêts', + 'roadtrip.day.fold': 'Replier', + 'roadtrip.day.folded': 'Replié', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer', 'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée', 'roadtrip.warn.overnight': 'Jour suivant', + 'roadtrip.spill.title': 'Du jour {number}', + 'roadtrip.spill.departs': 'départ {time}', 'roadtrip.summary.stops': 'Étapes', 'roadtrip.stay.add': 'Definir une duree', 'roadtrip.stay.none': 'Aucune duree', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/gr/admin.ts index f28c1ae12..4bc3b0b30 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -419,6 +419,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Παροχή συμβάντων στο ημερολόγιο', 'admin.plugins.perm.hook:place-detail-provider': 'Συνεισφορά επιπλέον λεπτομερειών (κριτικές, βαθμολογίες, σύνδεσμοι) σε ένα μέρος', + 'admin.plugins.perm.hook:search-provider': + 'Απάντηση σε αναζητήσεις τοποθεσιών από δικό του ευρετήριο, δίπλα στα αποτελέσματα του TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Εμφάνιση προειδοποιήσεων επικύρωσης σε ένα ταξίδι (εμφανίζονται στον σχεδιαστή)', 'admin.plugins.perm.hook:table-contributor': @@ -578,6 +580,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Παρέχει φωτογραφίες', 'admin.plugins.cap.calendar': 'Παρέχει συμβάντα ημερολογίου', 'admin.plugins.cap.placeDetails': 'Εμπλουτίζει μέρη', + 'admin.plugins.cap.search': 'Απαντά σε αναζητήσεις', 'admin.plugins.cap.warnings': 'Επισημαίνει ζητήματα', 'admin.plugins.cap.mapLayers': 'Σχεδιάζει στον χάρτη', 'admin.plugins.cap.routing': 'Παρέχει δρομολόγηση', 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/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index ceeb4c90c..62a33b696 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -18,17 +18,74 @@ 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': 'Αυτονομία με ένα γέμισμα', + 'roadtrip.limit.fillLabel': 'Γέμισμα έως', + 'roadtrip.limit.fillFull': 'γεμάτο', + 'roadtrip.limit.fillBadge': 'γεμίζει στο {percent} %', + 'roadtrip.limit.wearNote': '{percent} % χαμένο από τη φθορά', + 'roadtrip.limit.rangeLabelCharge': 'Αυτονομία ανά φόρτιση', + 'roadtrip.limit.sectionTrip': 'Αυτό το ταξίδι', + 'roadtrip.limit.sectionDriving': 'Οδήγηση', + 'roadtrip.limit.sectionVehicle': 'Όχημα', + 'roadtrip.limit.tankLabel': 'Ρεζερβουάρ', + 'roadtrip.limit.batteryLabel': 'Μπαταρία', + 'roadtrip.limit.useLabel': 'Κατανάλωση', + 'roadtrip.limit.wearLabel': 'Φθορά μπαταρίας', + 'roadtrip.limit.computed': 'Υπολογίζεται από τα στοιχεία του οχήματος', + 'roadtrip.limit.perFill': 'ανά γέμισμα', + 'roadtrip.limit.perCharge': 'ανά φόρτιση', + 'roadtrip.limit.afterFill': 'Στάση στο {percent} %: {distance}', + 'roadtrip.limit.fullNote': 'Μια στάση γεμίζει τελείως', + 'roadtrip.limit.rangeEmpty': 'Χωρίς αυτονομία', + 'roadtrip.limit.rangeEmptyHint': 'Δώσε μια αυτονομία ή συμπλήρωσε τα πεδία και το TREK την υπολογίζει.', + 'roadtrip.limit.specHint': 'Συμπληρωμένα, αυτά καθορίζουν την αυτονομία πάνω.', + 'roadtrip.limit.vehicleHint': 'Δήλωσε τι οδηγείς και δώσε τα στοιχεία του αντί για αυτονομία.', 'roadtrip.limit.minutes': 'λεπ', 'roadtrip.limit.off': 'ανενεργό', + 'roadtrip.limit.blockNote': 'Ένα μπλοκ = {distance}', + 'roadtrip.limit.echoOver': '{over} από {days} ημέρες πάνω από το όριο.', + 'roadtrip.limit.echoNone': 'Και οι {days} ημέρες εντός ορίων.', + 'roadtrip.limit.echoDry': '{count} τμήματα χωρίς προσβάσιμη στάση.', + 'roadtrip.limit.echoEmpty': 'Άνοιξε ένα ταξίδι για να δεις το αποτέλεσμα.', + 'roadtrip.limit.specToggle': 'Υπολογισμός από τα στοιχεία του οχήματος', 'roadtrip.limit.hint': 'Μετρά μόνο τον χρόνο οδήγησης. Οι στάσεις και οι διανυκτερεύσεις δεν περιλαμβάνονται.', 'roadtrip.limit.legOver': '{time} πάνω από τη μέγιστη οδήγησή σας', 'roadtrip.limit.dayOver': '{time} πάνω', 'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό', 'roadtrip.limit.none': 'Δεν έχουν οριστεί', + 'roadtrip.line.section': 'Γραμμή διαδρομής', + 'roadtrip.line.connect': 'Σύνδεση των ημερών', + 'roadtrip.line.dayColors': 'Ένα χρώμα ανά ημέρα', + 'roadtrip.line.hint': 'Υπολογίζει και το τμήμα ανάμεσα σε δύο ημέρες, στην ημέρα άφιξης.', + '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.refuel.dry': 'Εδώ αδειάζει το ρεζερβουάρ', + 'roadtrip.refuel.after': 'μετά από {distance}', + 'roadtrip.limit.vehicleLabel': 'Τι οδηγείς', + 'roadtrip.limit.vehicleAny': 'Και τα δύο', + 'roadtrip.limit.vehicleCombustion': 'Βενζίνη', + 'roadtrip.limit.vehicleElectric': 'Ηλεκτρικό', + 'roadtrip.refuel.dryElectric': 'Εδώ τελειώνει η μπαταρία', + 'roadtrip.refuel.findElectric': 'Εύρεση φόρτισης', + 'roadtrip.refuel.find': 'Εύρεση καυσίμων', + 'roadtrip.refuel.again': 'Ξανά', + 'roadtrip.refuel.looking': 'Αναζήτηση κατά μήκος της διαδρομής…', + 'roadtrip.refuel.spare': 'μένουν {distance}', + 'roadtrip.refuel.add': 'Προσθήκη {name} ως στάση ανεφοδιασμού', + 'roadtrip.refuel.addElectric': 'Προσθήκη {name} ως στάση φόρτισης', + 'roadtrip.refuel.none': 'Δεν βρέθηκε τίποτα στο προσβάσιμο τμήμα.', + 'roadtrip.refuel.incomplete': 'Η αναζήτηση κόπηκε, οπότε το τμήμα δεν ελέγχθηκε πλήρως.', + 'roadtrip.refuel.failed': 'Η αναζήτηση τοποθεσιών δεν απάντησε.', 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Μέχρι στιγμής μόνο {name} — αφήστε μια στάση εδώ', 'roadtrip.leg.driveText': '{distance} σε {time}', 'roadtrip.day.stopCount': '{count} στάσεις', + 'roadtrip.day.fold': 'Σύμπτυξη', + 'roadtrip.day.folded': 'Συμπτυγμένη', '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': 'Είδος στάσης', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση', 'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα', 'roadtrip.warn.overnight': 'Επόμενη μέρα', + 'roadtrip.spill.title': 'Από την ημέρα {number}', + 'roadtrip.spill.departs': 'αναχώρηση {time}', 'roadtrip.summary.stops': 'Στάσεις', 'roadtrip.stay.add': 'Ορισμος διαρκειας σταθμευσης', 'roadtrip.stay.none': 'Χωρις σταση', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/hu/admin.ts index da6b52e85..7b4cd4243 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -372,6 +372,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Események biztosítása a naptárhoz', 'admin.plugins.perm.hook:place-detail-provider': 'További részletek (értékelések, minősítések, hivatkozások) hozzáadása egy helyhez', + 'admin.plugins.perm.hook:search-provider': + 'Helykeresések megválaszolása saját indexből, a TREK saját találatai mellett', 'admin.plugins.perm.hook:trip-warning-provider': 'Ellenőrzési figyelmeztetések megjelenítése egy utazáson (a tervezőben látható)', 'admin.plugins.perm.hook:table-contributor': @@ -530,6 +532,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fotókat biztosít', 'admin.plugins.cap.calendar': 'Naptáreseményeket biztosít', 'admin.plugins.cap.placeDetails': 'Helyeket gazdagít', + 'admin.plugins.cap.search': 'Válaszol a keresésekre', 'admin.plugins.cap.warnings': 'Problémákat jelez', 'admin.plugins.cap.mapLayers': 'Rajzol a térképre', 'admin.plugins.cap.routing': 'Útvonaltervezést biztosít', 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/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 311c0f6a8..c99c320de 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Feltöltés eddig', + 'roadtrip.limit.fillFull': 'tele', + 'roadtrip.limit.fillBadge': '{percent} %-ig tölt', + 'roadtrip.limit.wearNote': '{percent} % elveszett az öregedéssel', + 'roadtrip.limit.rangeLabelCharge': 'Hatótáv egy töltéssel', + 'roadtrip.limit.sectionTrip': 'Ez az út', + 'roadtrip.limit.sectionDriving': 'Vezetés', + 'roadtrip.limit.sectionVehicle': 'Jármű', + 'roadtrip.limit.tankLabel': 'Tank mérete', + 'roadtrip.limit.batteryLabel': 'Akkumulátor', + 'roadtrip.limit.useLabel': 'Fogyasztás', + 'roadtrip.limit.wearLabel': 'Akkumulátor kopása', + 'roadtrip.limit.computed': 'A jármű adataiból számítva', + 'roadtrip.limit.perFill': 'tankonként', + 'roadtrip.limit.perCharge': 'töltésenként', + 'roadtrip.limit.afterFill': '{percent} %-ig töltve: {distance}', + 'roadtrip.limit.fullNote': 'Egy megálló teletölt', + 'roadtrip.limit.rangeEmpty': 'Nincs hatótáv', + 'roadtrip.limit.rangeEmptyHint': 'Add meg a hatótávot, vagy töltsd ki a mezőket, és a TREK kiszámolja.', + 'roadtrip.limit.specHint': 'Kitöltve ezek adják a fenti hatótávot.', + 'roadtrip.limit.vehicleHint': 'Add meg, mivel utazol, és hatótáv helyett a jármű adatait is megadhatod.', 'roadtrip.limit.minutes': 'perc', 'roadtrip.limit.off': 'ki', + 'roadtrip.limit.blockNote': 'Egy blokk = {distance}', + 'roadtrip.limit.echoOver': '{days} napból {over} a határ felett.', + 'roadtrip.limit.echoNone': 'Mind a(z) {days} nap a határokon belül.', + 'roadtrip.limit.echoDry': '{count} szakasz elérhető megálló nélkül.', + 'roadtrip.limit.echoEmpty': 'Nyiss meg egy utat, hogy lásd a hatást.', + 'roadtrip.limit.specToggle': 'Számold ki az autó adataiból', 'roadtrip.limit.hint': 'Csak a vezetési időt számolja. A megállók és az éjszakázások nincsenek benne.', 'roadtrip.limit.legOver': '{time} a leghosszabb vezetésed felett', 'roadtrip.limit.dayOver': '{time} túl', 'roadtrip.limit.range': '{distance} az utolsó tankolás óta', 'roadtrip.limit.none': 'Nincs beállítva', + 'roadtrip.line.section': 'Útvonalvonal', + 'roadtrip.line.connect': 'Napok összekötése', + 'roadtrip.line.dayColors': 'Naponta egy szín', + 'roadtrip.line.hint': 'A két nap közti szakaszt is kiszámolja, és az érkezés napjához adja.', + '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.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.dryElectric': 'Itt fogy el az akkumulátor', + 'roadtrip.refuel.findElectric': 'Töltő keresése', + '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', + 'roadtrip.refuel.addElectric': '{name} hozzáadása tölté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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Egyelőre csak {name} — húzzon ide egy megállót', 'roadtrip.leg.driveText': '{distance} {time} alatt', 'roadtrip.day.stopCount': '{count} megálló', + 'roadtrip.day.fold': 'Összecsuk', + 'roadtrip.day.folded': 'Összecsukva', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható', 'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után', 'roadtrip.warn.overnight': 'Következő nap', + 'roadtrip.spill.title': 'A(z) {number}. napról', + 'roadtrip.spill.departs': 'indulás {time}', 'roadtrip.summary.stops': 'Megállók', 'roadtrip.stay.add': 'Tartozkodas beallitasa', 'roadtrip.stay.none': 'Nincs tartozkodas', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/id/admin.ts index 00c554571..292f49e2a 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -416,6 +416,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Menyediakan acara untuk kalender', 'admin.plugins.perm.hook:place-detail-provider': 'Menyumbangkan detail tambahan (ulasan, peringkat, tautan) untuk suatu tempat', + 'admin.plugins.perm.hook:search-provider': 'Menjawab pencarian tempat dari indeks sendiri, di samping hasil TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Munculkan peringatan validasi pada perjalanan (ditampilkan di perencana)', 'admin.plugins.perm.hook:table-contributor': @@ -572,6 +573,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Menyediakan foto', 'admin.plugins.cap.calendar': 'Menyediakan acara kalender', 'admin.plugins.cap.placeDetails': 'Memperkaya tempat', + 'admin.plugins.cap.search': 'Menjawab pencarian', 'admin.plugins.cap.warnings': 'Tandai masalah', 'admin.plugins.cap.mapLayers': 'Menggambar di peta', 'admin.plugins.cap.routing': 'Menyediakan routing', 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/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 945da4563..9e0bd6d85 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Isi sampai', + 'roadtrip.limit.fillFull': 'penuh', + 'roadtrip.limit.fillBadge': 'isi sampai {percent} %', + 'roadtrip.limit.wearNote': '{percent} % hilang karena usia', + 'roadtrip.limit.rangeLabelCharge': 'Jarak per pengisian', + 'roadtrip.limit.sectionTrip': 'Perjalanan ini', + 'roadtrip.limit.sectionDriving': 'Mengemudi', + 'roadtrip.limit.sectionVehicle': 'Kendaraan', + 'roadtrip.limit.tankLabel': 'Kapasitas tangki', + 'roadtrip.limit.batteryLabel': 'Baterai', + 'roadtrip.limit.useLabel': 'Konsumsi', + 'roadtrip.limit.wearLabel': 'Penurunan baterai', + 'roadtrip.limit.computed': 'Dihitung dari data kendaraan', + 'roadtrip.limit.perFill': 'per tangki', + 'roadtrip.limit.perCharge': 'per pengisian', + 'roadtrip.limit.afterFill': 'Berhenti sampai {percent} %: {distance}', + 'roadtrip.limit.fullNote': 'Satu perhentian mengisi penuh', + 'roadtrip.limit.rangeEmpty': 'Belum ada jarak jangkau', + 'roadtrip.limit.rangeEmptyHint': 'Isi jarak jangkau, atau isi kolomnya dan TREK yang menghitung.', + 'roadtrip.limit.specHint': 'Bila diisi, kolom ini menentukan jarak jangkau di atas.', + 'roadtrip.limit.vehicleHint': 'Sebutkan jenis kendaraanmu, lalu isi datanya alih-alih jarak jangkau.', 'roadtrip.limit.minutes': 'mnt', 'roadtrip.limit.off': 'mati', + 'roadtrip.limit.blockNote': 'Satu blok = {distance}', + 'roadtrip.limit.echoOver': '{over} dari {days} hari melebihi batasmu.', + 'roadtrip.limit.echoNone': 'Semua {days} hari dalam batasmu.', + 'roadtrip.limit.echoDry': '{count} ruas tanpa perhentian terjangkau.', + 'roadtrip.limit.echoEmpty': 'Buka sebuah road trip untuk melihat efeknya.', + 'roadtrip.limit.specToggle': 'Hitung dari data mobil', 'roadtrip.limit.hint': 'Hanya menghitung waktu mengemudi. Waktu berhenti dan menginap tidak termasuk.', 'roadtrip.limit.legOver': '{time} lebih dari perjalanan terlama', 'roadtrip.limit.dayOver': 'lebih {time}', 'roadtrip.limit.range': '{distance} sejak pengisian terakhir', 'roadtrip.limit.none': 'Belum diatur', + 'roadtrip.line.section': 'Garis rute', + 'roadtrip.line.connect': 'Hubungkan hari', + 'roadtrip.line.dayColors': 'Satu warna per hari', + 'roadtrip.line.hint': 'Juga menghitung ruas antara dua hari dan memasukkannya ke hari kedatangan.', + '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.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.dryElectric': 'Baterai habis di sini', + 'roadtrip.refuel.findElectric': 'Cari pengisian daya', + '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', + 'roadtrip.refuel.addElectric': 'Tambahkan {name} sebagai perhentian pengisian daya', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Sejauh ini hanya {name} — jatuhkan perhentian di sini', 'roadtrip.leg.driveText': '{distance} dalam {time}', 'roadtrip.day.stopCount': '{count} perhentian', + 'roadtrip.day.fold': 'Lipat', + 'roadtrip.day.folded': 'Terlipat', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus', 'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan', 'roadtrip.warn.overnight': 'Hari berikutnya', + 'roadtrip.spill.title': 'Dari hari {number}', + 'roadtrip.spill.departs': 'berangkat {time}', 'roadtrip.summary.stops': 'Perhentian', 'roadtrip.stay.add': 'Atur lama singgah', 'roadtrip.stay.none': 'Tanpa singgah', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/it/admin.ts index 33393a5ba..1c07fe9ce 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Fornire eventi al calendario', 'admin.plugins.perm.hook:place-detail-provider': 'Fornire dettagli aggiuntivi (recensioni, valutazioni, link) a un luogo', + 'admin.plugins.perm.hook:search-provider': + 'Rispondere alle ricerche di luoghi dal proprio indice, accanto ai risultati di TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Segnala avvisi di validazione su un viaggio (mostrati nel pianificatore)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +527,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fornisce foto', 'admin.plugins.cap.calendar': 'Fornisce eventi calendario', 'admin.plugins.cap.placeDetails': 'Arricchisce luoghi', + 'admin.plugins.cap.search': 'Risponde alle ricerche', 'admin.plugins.cap.warnings': 'Segnala problemi', 'admin.plugins.cap.mapLayers': 'Disegna sulla mappa', 'admin.plugins.cap.routing': 'Offre routing', 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/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index c21e82999..65c37104f 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Riempi fino a', + 'roadtrip.limit.fillFull': 'pieno', + 'roadtrip.limit.fillBadge': 'riempie al {percent} %', + 'roadtrip.limit.wearNote': '{percent} % perso per l’età', + 'roadtrip.limit.rangeLabelCharge': 'Autonomia per ricarica', + 'roadtrip.limit.sectionTrip': 'Questo viaggio', + 'roadtrip.limit.sectionDriving': 'Guida', + 'roadtrip.limit.sectionVehicle': 'Veicolo', + 'roadtrip.limit.tankLabel': 'Serbatoio', + 'roadtrip.limit.batteryLabel': 'Batteria', + 'roadtrip.limit.useLabel': 'Consumo', + 'roadtrip.limit.wearLabel': 'Degrado batteria', + 'roadtrip.limit.computed': 'Calcolato dai dati del veicolo', + 'roadtrip.limit.perFill': 'per pieno', + 'roadtrip.limit.perCharge': 'per ricarica', + 'roadtrip.limit.afterFill': 'Una sosta al {percent} % dà {distance}', + 'roadtrip.limit.fullNote': 'Una sosta riempie del tutto', + 'roadtrip.limit.rangeEmpty': 'Nessuna autonomia', + 'roadtrip.limit.rangeEmptyHint': 'Inserisci un’autonomia, oppure compila i campi e TREK la calcola.', + 'roadtrip.limit.specHint': 'Se compilati, questi campi determinano l’autonomia sopra.', + 'roadtrip.limit.vehicleHint': 'Indica il tipo di veicolo per inserire i suoi dati invece dell’autonomia.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'off', + 'roadtrip.limit.blockNote': 'Un blocco = {distance}', + 'roadtrip.limit.echoOver': '{over} giorni su {days} superano il limite.', + 'roadtrip.limit.echoNone': 'Tutti i {days} giorni rientrano nei limiti.', + 'roadtrip.limit.echoDry': '{count} tratti senza sosta raggiungibile.', + 'roadtrip.limit.echoEmpty': 'Apri un viaggio per vedere l’effetto.', + 'roadtrip.limit.specToggle': 'Calcola dai dati dell’auto', 'roadtrip.limit.hint': 'Conta solo il tempo di guida. Le soste e i pernottamenti non sono inclusi.', 'roadtrip.limit.legOver': '{time} oltre il tuo tratto massimo', 'roadtrip.limit.dayOver': '{time} in più', 'roadtrip.limit.range': '{distance} dall’ultimo rifornimento', 'roadtrip.limit.none': 'Nessuno impostato', + 'roadtrip.line.section': 'Linea del percorso', + 'roadtrip.line.connect': 'Collega i giorni', + 'roadtrip.line.dayColors': 'Un colore per giorno', + 'roadtrip.line.hint': 'Calcola anche il tratto tra due giorni e lo conta nel giorno di arrivo.', + '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.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.dryElectric': 'Qui la batteria si esaurisce', + 'roadtrip.refuel.findElectric': 'Cerca una ricarica', + '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', + 'roadtrip.refuel.addElectric': 'Aggiungi {name} come sosta di ricarica', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Finora solo {name}: trascina qui una sosta', 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} soste', + 'roadtrip.day.fold': 'Chiudi', + 'roadtrip.day.folded': 'Chiuso', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere', 'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato', 'roadtrip.warn.overnight': 'Giorno successivo', + 'roadtrip.spill.title': 'Dal giorno {number}', + 'roadtrip.spill.departs': 'partenza {time}', 'roadtrip.summary.stops': 'Soste', 'roadtrip.stay.add': 'Imposta una sosta', 'roadtrip.stay.none': 'Nessuna sosta', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/ja/admin.ts index f34d3cced..4cbcc9bcf 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -387,6 +387,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '「写真」機能への写真の提供', 'admin.plugins.perm.hook:calendar-source': 'カレンダーへの予定の提供', 'admin.plugins.perm.hook:place-detail-provider': '場所に追加の詳細情報(レビュー、評価、リンク)を提供', + 'admin.plugins.perm.hook:search-provider': '独自のインデックスから場所の検索に回答(TREK 自身の結果と並べて表示)', 'admin.plugins.perm.hook:trip-warning-provider': '旅程に検証警告を表示(プランナー内に表示)', 'admin.plugins.perm.hook:table-contributor': '旅行のビュー(予約、場所、日程)に列とアクションを追加', 'admin.plugins.perm.hook:map-marker-provider': '旅行の地図にマーカーを追加(例: 予約や POI を表示)', @@ -537,6 +538,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '写真を提供', 'admin.plugins.cap.calendar': 'カレンダー予定を提供', 'admin.plugins.cap.placeDetails': '場所を充実', + 'admin.plugins.cap.search': '検索に回答', 'admin.plugins.cap.warnings': '問題を検出', 'admin.plugins.cap.mapLayers': '地図に描画', 'admin.plugins.cap.routing': 'ルーティングを提供', 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/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index d490eacb6..737707577 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -18,17 +18,74 @@ 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': '満タンでの航続', + 'roadtrip.limit.fillLabel': '補給する量', + 'roadtrip.limit.fillFull': '満タン', + 'roadtrip.limit.fillBadge': '{percent} % まで補給', + 'roadtrip.limit.wearNote': '劣化で {percent} % 減', + 'roadtrip.limit.rangeLabelCharge': '1回の充電での航続距離', + 'roadtrip.limit.sectionTrip': 'この旅', + 'roadtrip.limit.sectionDriving': '運転', + 'roadtrip.limit.sectionVehicle': '車両', + 'roadtrip.limit.tankLabel': 'タンク容量', + 'roadtrip.limit.batteryLabel': 'バッテリー容量', + 'roadtrip.limit.useLabel': '消費量', + 'roadtrip.limit.wearLabel': 'バッテリー劣化', + 'roadtrip.limit.computed': '車両データから算出', + 'roadtrip.limit.perFill': '満タンあたり', + 'roadtrip.limit.perCharge': '1回の充電で', + 'roadtrip.limit.afterFill': '{percent} % まで補給で {distance}', + 'roadtrip.limit.fullNote': '停車で満タンまで補給', + 'roadtrip.limit.rangeEmpty': '航続距離なし', + 'roadtrip.limit.rangeEmptyHint': '航続距離を入力するか、下の項目を埋めれば TREK が計算します。', + 'roadtrip.limit.specHint': '入力すると、上の航続距離はここから決まります。', + 'roadtrip.limit.vehicleHint': '何で走るかを選ぶと、航続距離の代わりに車両データを入力できます。', 'roadtrip.limit.minutes': '分', 'roadtrip.limit.off': 'なし', + 'roadtrip.limit.blockNote': '1ブロック = {distance}', + 'roadtrip.limit.echoOver': '{days}日のうち{over}日が上限超過。', + 'roadtrip.limit.echoNone': '{days}日すべてが上限内です。', + 'roadtrip.limit.echoDry': '到達できる立ち寄り先のない区間が{count}件。', + 'roadtrip.limit.echoEmpty': 'ロードトリップを開くと効果が見えます。', + 'roadtrip.limit.specToggle': '車のデータから計算する', 'roadtrip.limit.hint': '運転時間のみを数えます。滞在時間と宿泊は含みません。', 'roadtrip.limit.legOver': '連続運転の上限を{time}超過', 'roadtrip.limit.dayOver': '{time}超過', 'roadtrip.limit.range': '前回の給油から{distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.line.section': 'ルートの線', + 'roadtrip.line.connect': '日をつなぐ', + 'roadtrip.line.dayColors': '日ごとに色を分ける', + 'roadtrip.line.hint': '日と日の間の区間も計算し、到着した日に加えます。', + '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.refuel.dry': 'ここで燃料切れ', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '何で走るか', + 'roadtrip.limit.vehicleAny': '両方', + 'roadtrip.limit.vehicleCombustion': 'ガソリン', + 'roadtrip.limit.vehicleElectric': '電気', + 'roadtrip.refuel.dryElectric': 'ここでバッテリーが切れます', + 'roadtrip.refuel.findElectric': '充電スポットを探す', + 'roadtrip.refuel.find': '給油所を探す', + 'roadtrip.refuel.again': 'もう一度', + 'roadtrip.refuel.looking': 'ルート沿いを検索中…', + 'roadtrip.refuel.spare': '残り {distance}', + 'roadtrip.refuel.add': '{name} を給油の停車地として追加', + 'roadtrip.refuel.addElectric': '{name} を充電の立ち寄り先に追加', + 'roadtrip.refuel.none': '到達できる範囲には見つかりませんでした。', + 'roadtrip.refuel.incomplete': '検索が途中で打ち切られたため、この区間は完全には調べられていません。', + 'roadtrip.refuel.failed': '場所の検索が応答しませんでした。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': '今のところ {name} だけです。立ち寄り先をここにドロップ', 'roadtrip.leg.driveText': '{distance}/{time}', 'roadtrip.day.stopCount': '{count} 箇所', + 'roadtrip.day.fold': 'たたむ', + 'roadtrip.day.folded': 'たたみ済み', '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': '立ち寄りの種類', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除', 'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します', 'roadtrip.warn.overnight': '翌日', + 'roadtrip.spill.title': '{number}日目から', + 'roadtrip.spill.departs': '{time} 発', 'roadtrip.summary.stops': '立ち寄り先', 'roadtrip.stay.add': '滞在時間を設定', 'roadtrip.stay.none': '滞在なし', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/ko/admin.ts index 82749a5ad..7e9034bdc 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -389,6 +389,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '사진 기능에 사진 제공', 'admin.plugins.perm.hook:calendar-source': '캘린더에 일정 제공', 'admin.plugins.perm.hook:place-detail-provider': '장소에 추가 정보(리뷰, 평점, 링크)를 제공', + 'admin.plugins.perm.hook:search-provider': '자체 색인에서 장소 검색에 응답 (TREK 자체 결과와 함께 표시)', 'admin.plugins.perm.hook:trip-warning-provider': '여행에 검증 경고를 표시 (플래너에 표시됨)', 'admin.plugins.perm.hook:table-contributor': '여행 보기(예약, 장소, 일자)에 열과 작업 추가', 'admin.plugins.perm.hook:map-marker-provider': '여행 지도에 마커 추가(예: 예약 또는 POI 표시)', @@ -537,6 +538,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '사진 제공', 'admin.plugins.cap.calendar': '캘린더 일정 제공', 'admin.plugins.cap.placeDetails': '장소 정보 강화', + 'admin.plugins.cap.search': '검색에 응답', 'admin.plugins.cap.warnings': '문제 표시', 'admin.plugins.cap.mapLayers': '지도에 그리기', 'admin.plugins.cap.routing': '라우팅 제공', 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/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 133f792db..ec892396e 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -18,17 +18,74 @@ 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회 주유 주행거리', + 'roadtrip.limit.fillLabel': '충전 목표', + 'roadtrip.limit.fillFull': '가득', + 'roadtrip.limit.fillBadge': '{percent} %까지 충전', + 'roadtrip.limit.wearNote': '노후로 {percent} % 감소', + 'roadtrip.limit.rangeLabelCharge': '한 번 충전 주행거리', + 'roadtrip.limit.sectionTrip': '이번 여행', + 'roadtrip.limit.sectionDriving': '운전', + 'roadtrip.limit.sectionVehicle': '차량', + 'roadtrip.limit.tankLabel': '연료탱크', + 'roadtrip.limit.batteryLabel': '배터리 용량', + 'roadtrip.limit.useLabel': '소비량', + 'roadtrip.limit.wearLabel': '배터리 열화', + 'roadtrip.limit.computed': '차량 정보로 계산', + 'roadtrip.limit.perFill': '한 탱크당', + 'roadtrip.limit.perCharge': '한 번 충전당', + 'roadtrip.limit.afterFill': '{percent} %까지 충전 시 {distance}', + 'roadtrip.limit.fullNote': '정차 시 가득 충전', + 'roadtrip.limit.rangeEmpty': '주행거리 없음', + 'roadtrip.limit.rangeEmptyHint': '주행거리를 입력하거나 아래 항목을 채우면 TREK이 계산합니다.', + 'roadtrip.limit.specHint': '입력하면 위 주행거리는 이 값으로 정해집니다.', + 'roadtrip.limit.vehicleHint': '어떤 차인지 고르면 주행거리 대신 차량 정보를 입력할 수 있습니다.', 'roadtrip.limit.minutes': '분', 'roadtrip.limit.off': '끔', + 'roadtrip.limit.blockNote': '한 블록 = {distance}', + 'roadtrip.limit.echoOver': '{days}일 중 {over}일이 한도 초과.', + 'roadtrip.limit.echoNone': '{days}일 모두 한도 안입니다.', + 'roadtrip.limit.echoDry': '도달 가능한 정차지가 없는 구간 {count}곳.', + 'roadtrip.limit.echoEmpty': '로드트립을 열면 효과가 보입니다.', + 'roadtrip.limit.specToggle': '차량 데이터로 계산하기', 'roadtrip.limit.hint': '운전 시간만 계산합니다. 정차 시간과 숙박은 포함되지 않습니다.', 'roadtrip.limit.legOver': '연속 운전 한도 {time} 초과', 'roadtrip.limit.dayOver': '{time} 초과', 'roadtrip.limit.range': '마지막 주유 이후 {distance}', 'roadtrip.limit.none': '설정 안 됨', + 'roadtrip.line.section': '경로 선', + 'roadtrip.line.connect': '날짜 잇기', + 'roadtrip.line.dayColors': '날짜별 색상', + 'roadtrip.line.hint': '두 날 사이 구간도 계산해 도착한 날에 더합니다.', + '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.refuel.dry': '여기서 연료 소진', + 'roadtrip.refuel.after': '{distance} 뒤', + 'roadtrip.limit.vehicleLabel': '무엇으로 이동하나요', + 'roadtrip.limit.vehicleAny': '둘 다', + 'roadtrip.limit.vehicleCombustion': '연료', + 'roadtrip.limit.vehicleElectric': '전기', + 'roadtrip.refuel.dryElectric': '여기서 배터리가 바닥납니다', + 'roadtrip.refuel.findElectric': '충전소 찾기', + 'roadtrip.refuel.find': '주유소 찾기', + 'roadtrip.refuel.again': '다시 시도', + 'roadtrip.refuel.looking': '경로를 따라 찾는 중…', + 'roadtrip.refuel.spare': '{distance} 여유', + 'roadtrip.refuel.add': '{name}을(를) 주유 정차지로 추가', + 'roadtrip.refuel.addElectric': '{name}을(를) 충전 정차지로 추가', + 'roadtrip.refuel.none': '도달 가능한 구간에서 찾지 못했습니다.', + 'roadtrip.refuel.incomplete': '검색이 중단되어 이 구간을 완전히 확인하지 못했습니다.', + 'roadtrip.refuel.failed': '장소 검색이 응답하지 않았습니다.', 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': '지금은 {name}뿐입니다. 경유지를 여기에 놓으세요', 'roadtrip.leg.driveText': '{distance}, {time}', 'roadtrip.day.stopCount': '{count}곳', + 'roadtrip.day.fold': '접기', + 'roadtrip.day.folded': '접힘', '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': '경유지 종류', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제', 'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착', 'roadtrip.warn.overnight': '다음 날', + 'roadtrip.spill.title': '{number}일차에서', + 'roadtrip.spill.departs': '{time} 출발', 'roadtrip.summary.stops': '경유지', 'roadtrip.stay.add': '체류 시간 설정', 'roadtrip.stay.none': '체류 없음', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/nl/admin.ts index 249b6db67..c6e23c01e 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Evenementen aanleveren voor de kalender', 'admin.plugins.perm.hook:place-detail-provider': 'Extra details (reviews, beoordelingen, links) aan een plek toevoegen', + 'admin.plugins.perm.hook:search-provider': + 'Zoekopdrachten naar locaties beantwoorden vanuit een eigen index, naast de resultaten van TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Validatiewaarschuwingen tonen bij een reis (zichtbaar in de planner)', 'admin.plugins.perm.hook:table-contributor': @@ -524,6 +526,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': "Levert foto's", 'admin.plugins.cap.calendar': 'Levert kalenderevenementen', 'admin.plugins.cap.placeDetails': 'Verrijkt plekken', + 'admin.plugins.cap.search': 'Beantwoordt zoekopdrachten', 'admin.plugins.cap.warnings': 'Signaleert problemen', 'admin.plugins.cap.mapLayers': 'Tekent op de kaart', 'admin.plugins.cap.routing': 'Biedt routering aan', 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/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 2f21a2bb5..346547515 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Vullen tot', + 'roadtrip.limit.fillFull': 'vol', + 'roadtrip.limit.fillBadge': 'vult tot {percent} %', + 'roadtrip.limit.wearNote': '{percent} % verloren door veroudering', + 'roadtrip.limit.rangeLabelCharge': 'Actieradius per lading', + 'roadtrip.limit.sectionTrip': 'Deze reis', + 'roadtrip.limit.sectionDriving': 'Rijden', + 'roadtrip.limit.sectionVehicle': 'Voertuig', + 'roadtrip.limit.tankLabel': 'Tankinhoud', + 'roadtrip.limit.batteryLabel': 'Accu', + 'roadtrip.limit.useLabel': 'Verbruik', + 'roadtrip.limit.wearLabel': 'Accuslijtage', + 'roadtrip.limit.computed': 'Berekend uit de voertuiggegevens', + 'roadtrip.limit.perFill': 'per tankbeurt', + 'roadtrip.limit.perCharge': 'per lading', + 'roadtrip.limit.afterFill': 'Een stop tot {percent} % geeft {distance}', + 'roadtrip.limit.fullNote': 'Een stop maakt weer helemaal vol', + 'roadtrip.limit.rangeEmpty': 'Geen actieradius', + 'roadtrip.limit.rangeEmptyHint': 'Vul een actieradius in, of vul de velden in en TREK rekent hem uit.', + 'roadtrip.limit.specHint': 'Ingevuld bepalen deze velden de actieradius hierboven.', + 'roadtrip.limit.vehicleHint': 'Geef aan wat je rijdt, dan kun je de voertuiggegevens invullen in plaats van een actieradius.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'uit', + 'roadtrip.limit.blockNote': 'Eén blok = {distance}', + 'roadtrip.limit.echoOver': '{over} van {days} dagen boven je limiet.', + 'roadtrip.limit.echoNone': 'Alle {days} dagen binnen je limieten.', + 'roadtrip.limit.echoDry': '{count} stukken zonder bereikbare stop.', + 'roadtrip.limit.echoEmpty': 'Open een roadtrip om het effect te zien.', + 'roadtrip.limit.specToggle': 'Uit de autogegevens berekenen', 'roadtrip.limit.hint': 'Telt alleen rijtijd. Tijd bij stops en overnachtingen tellen niet mee.', 'roadtrip.limit.legOver': '{time} boven je langste rit', 'roadtrip.limit.dayOver': '{time} te veel', 'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt', 'roadtrip.limit.none': 'Geen ingesteld', + 'roadtrip.line.section': 'Routelijn', + 'roadtrip.line.connect': 'Dagen verbinden', + 'roadtrip.line.dayColors': 'Een kleur per dag', + 'roadtrip.line.hint': 'Berekent ook het stuk tussen twee dagen en telt het bij de aankomstdag.', + '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.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.dryElectric': 'Hier is de accu leeg', + 'roadtrip.refuel.findElectric': 'Laadpunt zoeken', + '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', + 'roadtrip.refuel.addElectric': '{name} als laadstop 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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Voorlopig alleen {name} — laat hier een stop vallen', 'roadtrip.leg.driveText': '{distance} in {time}', 'roadtrip.day.stopCount': '{count} stops', + 'roadtrip.day.fold': 'Inklappen', + 'roadtrip.day.folded': 'Ingeklapt', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen', 'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd', 'roadtrip.warn.overnight': 'Volgende dag', + 'roadtrip.spill.title': 'Van dag {number}', + 'roadtrip.spill.departs': 'vertrek {time}', 'roadtrip.summary.stops': 'Stops', 'roadtrip.stay.add': 'Verblijf instellen', 'roadtrip.stay.none': 'Geen verblijf', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/pl/admin.ts index 48b31707c..12b920089 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -364,6 +364,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Dostarczanie wydarzeń do kalendarza', 'admin.plugins.perm.hook:place-detail-provider': 'Dodawanie dodatkowych szczegółów (recenzji, ocen, linków) do miejsca', + 'admin.plugins.perm.hook:search-provider': 'Odpowiadać na wyszukiwania miejsc z własnego indeksu, obok wyników TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Zgłaszanie ostrzeżeń walidacyjnych dla podróży (widocznych w planerze)', 'admin.plugins.perm.hook:table-contributor': 'Dodawanie kolumn i akcji do widoków podróży (rezerwacje, miejsca, dni)', @@ -522,6 +523,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Dostarcza zdjęcia', 'admin.plugins.cap.calendar': 'Dostarcza wydarzenia kalendarza', 'admin.plugins.cap.placeDetails': 'Wzbogaca miejsca', + 'admin.plugins.cap.search': 'Odpowiada na wyszukiwania', 'admin.plugins.cap.warnings': 'Zgłasza problemy', 'admin.plugins.cap.mapLayers': 'Rysuje na mapie', 'admin.plugins.cap.routing': 'Oferuje trasowanie', 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/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 1a47cd41e..e1d8f8200 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Tankuj do', + 'roadtrip.limit.fillFull': 'pełny', + 'roadtrip.limit.fillBadge': 'tankuje do {percent} %', + 'roadtrip.limit.wearNote': '{percent} % utracone przez zużycie', + 'roadtrip.limit.rangeLabelCharge': 'Zasięg na ładowaniu', + 'roadtrip.limit.sectionTrip': 'Ta podróż', + 'roadtrip.limit.sectionDriving': 'Jazda', + 'roadtrip.limit.sectionVehicle': 'Pojazd', + 'roadtrip.limit.tankLabel': 'Pojemność baku', + 'roadtrip.limit.batteryLabel': 'Akumulator', + 'roadtrip.limit.useLabel': 'Zużycie', + 'roadtrip.limit.wearLabel': 'Zużycie akumulatora', + 'roadtrip.limit.computed': 'Obliczone z danych pojazdu', + 'roadtrip.limit.perFill': 'na baku', + 'roadtrip.limit.perCharge': 'na ładowaniu', + 'roadtrip.limit.afterFill': 'Postój do {percent} % daje {distance}', + 'roadtrip.limit.fullNote': 'Postój napełnia do pełna', + 'roadtrip.limit.rangeEmpty': 'Brak zasięgu', + 'roadtrip.limit.rangeEmptyHint': 'Wpisz zasięg albo wypełnij pola, a TREK go obliczy.', + 'roadtrip.limit.specHint': 'Wypełnione, te pola wyznaczają zasięg powyżej.', + 'roadtrip.limit.vehicleHint': 'Podaj rodzaj pojazdu, a zamiast zasięgu wpiszesz jego dane.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'wył.', + 'roadtrip.limit.blockNote': 'Jeden blok = {distance}', + 'roadtrip.limit.echoOver': '{over} z {days} dni ponad limit.', + 'roadtrip.limit.echoNone': 'Wszystkie {days} dni w granicach.', + 'roadtrip.limit.echoDry': '{count} odcinków bez osiągalnego postoju.', + 'roadtrip.limit.echoEmpty': 'Otwórz road trip, aby zobaczyć efekt.', + 'roadtrip.limit.specToggle': 'Policz z danych auta', 'roadtrip.limit.hint': 'Liczy tylko czas jazdy. Postoje i noclegi nie są wliczane.', 'roadtrip.limit.legOver': '{time} ponad twoją najdłuższą jazdę', 'roadtrip.limit.dayOver': '{time} ponad', 'roadtrip.limit.range': '{distance} od ostatniego tankowania', 'roadtrip.limit.none': 'Brak ustawionych', + 'roadtrip.line.section': 'Linia trasy', + 'roadtrip.line.connect': 'Połącz dni', + 'roadtrip.line.dayColors': 'Kolor na dzień', + 'roadtrip.line.hint': 'Wyznacza też odcinek między dniami i liczy go do dnia przyjazdu.', + '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.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.dryElectric': 'Tu kończy się bateria', + 'roadtrip.refuel.findElectric': 'Znajdź ładowarkę', + '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', + 'roadtrip.refuel.addElectric': 'Dodaj {name} jako postój na ładowanie', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Na razie tylko {name} — upuść tutaj przystanek', 'roadtrip.leg.driveText': '{distance} w {time}', 'roadtrip.day.stopCount': '{count} przystanków', + 'roadtrip.day.fold': 'Zwiń', + 'roadtrip.day.folded': 'Zwinięty', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć', 'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie', 'roadtrip.warn.overnight': 'Następny dzień', + 'roadtrip.spill.title': 'Z dnia {number}', + 'roadtrip.spill.departs': 'odjazd {time}', 'roadtrip.summary.stops': 'Przystanki', 'roadtrip.stay.add': 'Ustaw czas postoju', 'roadtrip.stay.none': 'Bez postoju', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/ru/admin.ts index 22f69a8cd..6e5021a94 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Предоставлять фотографии для «Воспоминаний»', 'admin.plugins.perm.hook:calendar-source': 'Предоставлять события для календаря', 'admin.plugins.perm.hook:place-detail-provider': 'Добавлять дополнительные сведения (отзывы, оценки, ссылки) к месту', + 'admin.plugins.perm.hook:search-provider': + 'Отвечать на поиск мест из собственного индекса, рядом с результатами TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Выдавать предупреждения проверки для поездки (отображаются в планировщике)', 'admin.plugins.perm.hook:table-contributor': @@ -526,6 +528,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Предоставляет фото', 'admin.plugins.cap.calendar': 'Предоставляет события календаря', 'admin.plugins.cap.placeDetails': 'Обогащает места', + 'admin.plugins.cap.search': 'Отвечает на поиск', 'admin.plugins.cap.warnings': 'Отмечает проблемы', 'admin.plugins.cap.mapLayers': 'Рисует на карте', 'admin.plugins.cap.routing': 'Предоставляет маршрутизацию', 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/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 488e7827b..c2b383816 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -18,17 +18,74 @@ 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': 'Запас хода на баке', + 'roadtrip.limit.fillLabel': 'Заправлять до', + 'roadtrip.limit.fillFull': 'полный', + 'roadtrip.limit.fillBadge': 'заправка до {percent} %', + 'roadtrip.limit.wearNote': '{percent} % потеряно из-за износа', + 'roadtrip.limit.rangeLabelCharge': 'Запас хода на заряде', + 'roadtrip.limit.sectionTrip': 'Эта поездка', + 'roadtrip.limit.sectionDriving': 'Вождение', + 'roadtrip.limit.sectionVehicle': 'Автомобиль', + 'roadtrip.limit.tankLabel': 'Объём бака', + 'roadtrip.limit.batteryLabel': 'Батарея', + 'roadtrip.limit.useLabel': 'Расход', + 'roadtrip.limit.wearLabel': 'Износ батареи', + 'roadtrip.limit.computed': 'Рассчитано по данным автомобиля', + 'roadtrip.limit.perFill': 'на баке', + 'roadtrip.limit.perCharge': 'на заряде', + 'roadtrip.limit.afterFill': 'Остановка до {percent} %: {distance}', + 'roadtrip.limit.fullNote': 'Остановка заправляет полностью', + 'roadtrip.limit.rangeEmpty': 'Запас хода не задан', + 'roadtrip.limit.rangeEmptyHint': 'Укажите запас хода или заполните поля, и TREK посчитает сам.', + 'roadtrip.limit.specHint': 'Заполненные, эти поля задают запас хода выше.', + 'roadtrip.limit.vehicleHint': 'Укажите тип машины, и вместо запаса хода можно ввести её данные.', 'roadtrip.limit.minutes': 'мин', 'roadtrip.limit.off': 'выкл', + 'roadtrip.limit.blockNote': 'Один блок = {distance}', + 'roadtrip.limit.echoOver': '{over} из {days} дней сверх лимита.', + 'roadtrip.limit.echoNone': 'Все {days} дней в пределах лимитов.', + 'roadtrip.limit.echoDry': '{count} участков без достижимой остановки.', + 'roadtrip.limit.echoEmpty': 'Откройте поездку, чтобы увидеть эффект.', + 'roadtrip.limit.specToggle': 'Посчитать по данным авто', 'roadtrip.limit.hint': 'Учитывается только время за рулём. Остановки и ночёвки не входят.', 'roadtrip.limit.legOver': 'на {time} дольше вашего перегона', 'roadtrip.limit.dayOver': 'на {time} больше', 'roadtrip.limit.range': '{distance} с последней заправки', 'roadtrip.limit.none': 'Не заданы', + 'roadtrip.line.section': 'Линия маршрута', + 'roadtrip.line.connect': 'Соединить дни', + 'roadtrip.line.dayColors': 'Цвет на день', + 'roadtrip.line.hint': 'Также считает участок между двумя днями и относит его ко дню прибытия.', + '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.refuel.dry': 'Здесь кончится топливо', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'На чём вы едете', + 'roadtrip.limit.vehicleAny': 'Оба', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Электро', + 'roadtrip.refuel.dryElectric': 'Здесь сядет батарея', + 'roadtrip.refuel.findElectric': 'Найти зарядку', + 'roadtrip.refuel.find': 'Найти заправку', + 'roadtrip.refuel.again': 'Ещё раз', + 'roadtrip.refuel.looking': 'Ищу вдоль маршрута…', + 'roadtrip.refuel.spare': 'останется {distance}', + 'roadtrip.refuel.add': 'Добавить {name} как остановку для заправки', + 'roadtrip.refuel.addElectric': 'Добавить {name} как остановку для зарядки', + 'roadtrip.refuel.none': 'На доступном участке ничего не найдено.', + 'roadtrip.refuel.incomplete': 'Поиск был обрезан, поэтому участок проверен не полностью.', + 'roadtrip.refuel.failed': 'Поиск мест не ответил.', 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Пока только {name} — перетащите сюда остановку', 'roadtrip.leg.driveText': '{distance} за {time}', 'roadtrip.day.stopCount': 'остановок: {count}', + 'roadtrip.day.fold': 'Свернуть', + 'roadtrip.day.folded': 'Свёрнут', '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': 'Тип остановки', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить', 'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени', 'roadtrip.warn.overnight': 'Следующий день', + 'roadtrip.spill.title': 'Со дня {number}', + 'roadtrip.spill.departs': 'выезд {time}', 'roadtrip.summary.stops': 'Остановки', 'roadtrip.stay.add': 'Задать время остановки', 'roadtrip.stay.none': 'Без остановки', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/sv/admin.ts index 0d5e67cc8..67358753d 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -427,6 +427,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Tillhandahålla händelser till kalendern', 'admin.plugins.perm.hook:place-detail-provider': 'Bidra med extra detaljer (recensioner, betyg, länkar) till en plats', + 'admin.plugins.perm.hook:search-provider': + 'Besvara platssökningar från ett eget index, vid sidan av TREK:s egna resultat', 'admin.plugins.perm.hook:trip-warning-provider': 'Visa valideringsvarningar på en resa (visas i planeraren)', 'admin.plugins.perm.hook:table-contributor': 'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)', @@ -583,6 +585,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Tillhandahåller foton', 'admin.plugins.cap.calendar': 'Tillhandahåller kalenderhändelser', 'admin.plugins.cap.placeDetails': 'Berikar platser', + 'admin.plugins.cap.search': 'Besvarar sökningar', 'admin.plugins.cap.warnings': 'Flaggar problem', 'admin.plugins.cap.mapLayers': 'Ritar på kartan', 'admin.plugins.cap.routing': 'Erbjuder ruttplanering', 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/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index efc90368f..8fccfd389 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Fyll till', + 'roadtrip.limit.fillFull': 'fullt', + 'roadtrip.limit.fillBadge': 'fyller till {percent} %', + 'roadtrip.limit.wearNote': '{percent} % förlorat med åldern', + 'roadtrip.limit.rangeLabelCharge': 'Räckvidd per laddning', + 'roadtrip.limit.sectionTrip': 'Den här resan', + 'roadtrip.limit.sectionDriving': 'Körning', + 'roadtrip.limit.sectionVehicle': 'Fordon', + 'roadtrip.limit.tankLabel': 'Tankvolym', + 'roadtrip.limit.batteryLabel': 'Batteri', + 'roadtrip.limit.useLabel': 'Förbrukning', + 'roadtrip.limit.wearLabel': 'Batterislitage', + 'roadtrip.limit.computed': 'Beräknat från fordonets uppgifter', + 'roadtrip.limit.perFill': 'per tank', + 'roadtrip.limit.perCharge': 'per laddning', + 'roadtrip.limit.afterFill': 'Ett stopp till {percent} % ger {distance}', + 'roadtrip.limit.fullNote': 'Ett stopp fyller helt', + 'roadtrip.limit.rangeEmpty': 'Ingen räckvidd', + 'roadtrip.limit.rangeEmptyHint': 'Ange en räckvidd, eller fyll i fälten så räknar TREK ut den.', + 'roadtrip.limit.specHint': 'Ifyllda bestämmer de här fälten räckvidden ovan.', + 'roadtrip.limit.vehicleHint': 'Ange vad du kör så kan du fylla i fordonets uppgifter i stället för en räckvidd.', 'roadtrip.limit.minutes': 'min', 'roadtrip.limit.off': 'av', + 'roadtrip.limit.blockNote': 'Ett block = {distance}', + 'roadtrip.limit.echoOver': '{over} av {days} dagar över din gräns.', + 'roadtrip.limit.echoNone': 'Alla {days} dagar inom dina gränser.', + 'roadtrip.limit.echoDry': '{count} sträckor utan nåbart stopp.', + 'roadtrip.limit.echoEmpty': 'Öppna en roadtrip för att se effekten.', + 'roadtrip.limit.specToggle': 'Räkna fram ur bilens data', 'roadtrip.limit.hint': 'Räknar bara körtid. Tid vid stopp och övernattningar ingår inte.', 'roadtrip.limit.legOver': '{time} över din längsta körning', 'roadtrip.limit.dayOver': '{time} över', 'roadtrip.limit.range': '{distance} sedan senaste tankningen', 'roadtrip.limit.none': 'Inga angivna', + 'roadtrip.line.section': 'Ruttlinje', + 'roadtrip.line.connect': 'Koppla ihop dagarna', + 'roadtrip.line.dayColors': 'En färg per dag', + 'roadtrip.line.hint': 'Beräknar även sträckan mellan två dagar och räknar den på ankomstdagen.', + '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.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.dryElectric': 'Här tar batteriet slut', + 'roadtrip.refuel.findElectric': 'Hitta laddning', + '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', + 'roadtrip.refuel.addElectric': 'Lägg till {name} som laddstopp', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Hittills bara {name} – släpp ett stopp här', 'roadtrip.leg.driveText': '{distance} på {time}', 'roadtrip.day.stopCount': '{count} stopp', + 'roadtrip.day.fold': 'Fäll ihop', + 'roadtrip.day.folded': 'Ihopfälld', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort', 'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden', 'roadtrip.warn.overnight': 'Nästa dag', + 'roadtrip.spill.title': 'Från dag {number}', + 'roadtrip.spill.departs': 'avgång {time}', 'roadtrip.summary.stops': 'Stopp', 'roadtrip.stay.add': 'Ange vistelse', 'roadtrip.stay.none': 'Ingen vistelse', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/tr/admin.ts index f9cdae94d..febaa42ac 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -413,6 +413,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Takvime etkinlik sağlar', 'admin.plugins.perm.hook:place-detail-provider': 'Bir yere ek ayrıntılar (yorumlar, puanlar, bağlantılar) katkıda bulunur', + 'admin.plugins.perm.hook:search-provider': + "Yer aramalarını kendi dizininden yanıtlama, TREK'in kendi sonuçlarının yanında", 'admin.plugins.perm.hook:trip-warning-provider': 'Bir gezide doğrulama uyarıları oluşturur (planlayıcıda gösterilir)', 'admin.plugins.perm.hook:table-contributor': 'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)', @@ -569,6 +571,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fotoğraf sağlar', 'admin.plugins.cap.calendar': 'Takvim etkinlikleri sağlar', 'admin.plugins.cap.placeDetails': 'Yerleri zenginleştirir', + 'admin.plugins.cap.search': 'Aramaları yanıtlar', 'admin.plugins.cap.warnings': 'Sorunları işaretler', 'admin.plugins.cap.mapLayers': 'Haritada çizim yapar', 'admin.plugins.cap.routing': 'Rota sunar', 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/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 5fc67b61e..18127622f 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Şu kadar doldur', + 'roadtrip.limit.fillFull': 'dolu', + 'roadtrip.limit.fillBadge': '%{percent} doldurur', + 'roadtrip.limit.wearNote': '%{percent} yaşlanmayla kayıp', + 'roadtrip.limit.rangeLabelCharge': 'Şarj başına menzil', + 'roadtrip.limit.sectionTrip': 'Bu gezi', + 'roadtrip.limit.sectionDriving': 'Sürüş', + 'roadtrip.limit.sectionVehicle': 'Araç', + 'roadtrip.limit.tankLabel': 'Depo hacmi', + 'roadtrip.limit.batteryLabel': 'Batarya', + 'roadtrip.limit.useLabel': 'Tüketim', + 'roadtrip.limit.wearLabel': 'Batarya yıpranması', + 'roadtrip.limit.computed': 'Araç verilerinden hesaplandı', + 'roadtrip.limit.perFill': 'depo başına', + 'roadtrip.limit.perCharge': 'şarj başına', + 'roadtrip.limit.afterFill': '%{percent} dolum: {distance}', + 'roadtrip.limit.fullNote': 'Bir mola tamamen doldurur', + 'roadtrip.limit.rangeEmpty': 'Menzil yok', + 'roadtrip.limit.rangeEmptyHint': 'Bir menzil gir ya da alanları doldur, TREK hesaplasın.', + 'roadtrip.limit.specHint': 'Doldurulduğunda bu alanlar yukarıdaki menzili belirler.', + 'roadtrip.limit.vehicleHint': 'Ne kullandığını seç, menzil yerine araç verilerini girebilirsin.', 'roadtrip.limit.minutes': 'dk', 'roadtrip.limit.off': 'kapalı', + 'roadtrip.limit.blockNote': 'Bir blok = {distance}', + 'roadtrip.limit.echoOver': '{days} günün {over} tanesi sınırının üzerinde.', + 'roadtrip.limit.echoNone': '{days} günün tamamı sınırlar içinde.', + 'roadtrip.limit.echoDry': 'Ulaşılabilir durağı olmayan {count} kesim.', + 'roadtrip.limit.echoEmpty': 'Etkiyi görmek için bir yol gezisi aç.', + 'roadtrip.limit.specToggle': 'Araç verilerinden hesapla', 'roadtrip.limit.hint': 'Yalnızca sürüş süresini sayar. Molalar ve geceleme dahil değildir.', 'roadtrip.limit.legOver': 'en uzun sürüşünüzden {time} fazla', 'roadtrip.limit.dayOver': '{time} fazla', 'roadtrip.limit.range': 'son yakıt alımından beri {distance}', 'roadtrip.limit.none': 'Ayarlanmadı', + 'roadtrip.line.section': 'Rota çizgisi', + 'roadtrip.line.connect': 'Günleri birleştir', + 'roadtrip.line.dayColors': 'Her güne bir renk', + 'roadtrip.line.hint': 'İki gün arasındaki yolu da hesaplar ve varış gününe sayar.', + '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.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.dryElectric': 'Batarya burada bitiyor', + 'roadtrip.refuel.findElectric': 'Şarj noktası bul', + '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', + 'roadtrip.refuel.addElectric': '{name} şarj molası olarak ekle', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Şimdilik yalnızca {name} — buraya bir mola bırakın', 'roadtrip.leg.driveText': '{time} içinde {distance}', 'roadtrip.day.stopCount': '{count} mola', + 'roadtrip.day.fold': 'Katla', + 'roadtrip.day.folded': 'Katlandı', '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ü', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın', 'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış', 'roadtrip.warn.overnight': 'Ertesi gün', + 'roadtrip.spill.title': '{number}. günden', + 'roadtrip.spill.departs': 'kalkış {time}', 'roadtrip.summary.stops': 'Duraklar', 'roadtrip.stay.add': 'Mola suresi ekle', 'roadtrip.stay.none': 'Mola yok', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/uk/admin.ts index 58b325241..803061139 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -366,6 +366,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Надавати фотографії для Спогадів', 'admin.plugins.perm.hook:calendar-source': 'Надавати події для календаря', 'admin.plugins.perm.hook:place-detail-provider': 'Додавати додаткові деталі (відгуки, оцінки, посилання) до місця', + 'admin.plugins.perm.hook:search-provider': + 'Відповідати на пошук місць із власного індексу, поряд із результатами TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Створювати попередження перевірки для подорожі (показуються в планувальнику)', 'admin.plugins.perm.hook:table-contributor': 'Додавати стовпці та дії до подань подорожі (бронювання, місця, дні)', @@ -523,6 +525,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Надає фотографії', 'admin.plugins.cap.calendar': 'Надає події календаря', 'admin.plugins.cap.placeDetails': 'Збагачує місця', + 'admin.plugins.cap.search': 'Відповідає на пошук', 'admin.plugins.cap.warnings': 'Позначає проблеми', 'admin.plugins.cap.mapLayers': 'Малює на карті', 'admin.plugins.cap.routing': 'Пропонує маршрутизацію', 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/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 870a9033d..930990fd0 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -18,17 +18,74 @@ 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': 'Запас ходу на баку', + 'roadtrip.limit.fillLabel': 'Заправляти до', + 'roadtrip.limit.fillFull': 'повний', + 'roadtrip.limit.fillBadge': 'заправка до {percent} %', + 'roadtrip.limit.wearNote': '{percent} % втрачено через знос', + 'roadtrip.limit.rangeLabelCharge': 'Запас ходу на заряді', + 'roadtrip.limit.sectionTrip': 'Ця подорож', + 'roadtrip.limit.sectionDriving': 'Водіння', + 'roadtrip.limit.sectionVehicle': 'Автомобіль', + 'roadtrip.limit.tankLabel': 'Обʼєм бака', + 'roadtrip.limit.batteryLabel': 'Батарея', + 'roadtrip.limit.useLabel': 'Витрата', + 'roadtrip.limit.wearLabel': 'Знос батареї', + 'roadtrip.limit.computed': 'Розраховано за даними автомобіля', + 'roadtrip.limit.perFill': 'на баку', + 'roadtrip.limit.perCharge': 'на заряді', + 'roadtrip.limit.afterFill': 'Зупинка до {percent} %: {distance}', + 'roadtrip.limit.fullNote': 'Зупинка заправляє повністю', + 'roadtrip.limit.rangeEmpty': 'Запас ходу не задано', + 'roadtrip.limit.rangeEmptyHint': 'Вкажіть запас ходу або заповніть поля, і TREK порахує сам.', + 'roadtrip.limit.specHint': 'Заповнені, ці поля задають запас ходу вище.', + 'roadtrip.limit.vehicleHint': 'Вкажіть тип авто, і замість запасу ходу можна ввести його дані.', 'roadtrip.limit.minutes': 'хв', 'roadtrip.limit.off': 'вимк', + 'roadtrip.limit.blockNote': 'Один блок = {distance}', + 'roadtrip.limit.echoOver': '{over} із {days} днів понад твій ліміт.', + 'roadtrip.limit.echoNone': 'Усі {days} днів у межах лімітів.', + 'roadtrip.limit.echoDry': '{count} відрізків без досяжної зупинки.', + 'roadtrip.limit.echoEmpty': 'Відкрий роуд-тріп, щоб побачити ефект.', + 'roadtrip.limit.specToggle': 'Порахувати з даних авто', 'roadtrip.limit.hint': 'Рахує лише час за кермом. Зупинки та ночівлі не враховано.', 'roadtrip.limit.legOver': 'на {time} довше вашого перегону', 'roadtrip.limit.dayOver': 'на {time} більше', 'roadtrip.limit.range': '{distance} від останньої заправки', 'roadtrip.limit.none': 'Не задано', + 'roadtrip.line.section': 'Лінія маршруту', + 'roadtrip.line.connect': 'З’єднати дні', + 'roadtrip.line.dayColors': 'Колір на день', + 'roadtrip.line.hint': 'Також рахує ділянку між двома днями і зараховує її дню прибуття.', + '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.refuel.dry': 'Тут закінчиться пальне', + 'roadtrip.refuel.after': 'через {distance}', + 'roadtrip.limit.vehicleLabel': 'Чим ви їдете', + 'roadtrip.limit.vehicleAny': 'Обидва', + 'roadtrip.limit.vehicleCombustion': 'Бензин', + 'roadtrip.limit.vehicleElectric': 'Електро', + 'roadtrip.refuel.dryElectric': 'Тут акумулятор сяде', + 'roadtrip.refuel.findElectric': 'Знайти зарядку', + 'roadtrip.refuel.find': 'Знайти заправку', + 'roadtrip.refuel.again': 'Ще раз', + 'roadtrip.refuel.looking': 'Шукаю вздовж маршруту…', + 'roadtrip.refuel.spare': 'лишиться {distance}', + 'roadtrip.refuel.add': 'Додати {name} як зупинку для заправки', + 'roadtrip.refuel.addElectric': 'Додати {name} як зупинку для зарядки', + 'roadtrip.refuel.none': 'На доступній ділянці нічого не знайдено.', + 'roadtrip.refuel.incomplete': 'Пошук було обрізано, тож ділянку перевірено не повністю.', + 'roadtrip.refuel.failed': 'Пошук місць не відповів.', 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Поки лише {name} — перетягніть сюди зупинку', 'roadtrip.leg.driveText': '{distance} за {time}', 'roadtrip.day.stopCount': 'зупинок: {count}', + 'roadtrip.day.fold': 'Згорнути', + 'roadtrip.day.folded': 'Згорнуто', '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': 'Тип зупинки', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити', 'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу', 'roadtrip.warn.overnight': 'Наступний день', + 'roadtrip.spill.title': 'З дня {number}', + 'roadtrip.spill.departs': 'виїзд {time}', 'roadtrip.summary.stops': 'Зупинки', 'roadtrip.stay.add': 'Задати час зупинки', 'roadtrip.stay.none': 'Без зупинки', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/vi/admin.ts index b9ed6806e..4ad777d66 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -368,6 +368,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Cung cấp sự kiện cho lịch', 'admin.plugins.perm.hook:place-detail-provider': 'Đóng góp thêm thông tin chi tiết (đánh giá, xếp hạng, liên kết) cho một địa điểm', + 'admin.plugins.perm.hook:search-provider': 'Trả lời tìm kiếm địa điểm từ chỉ mục riêng, bên cạnh kết quả của TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Đưa ra cảnh báo xác thực trên một chuyến đi (hiển thị trong trình lập kế hoạch)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +526,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Cung cấp ảnh', 'admin.plugins.cap.calendar': 'Cung cấp sự kiện lịch', 'admin.plugins.cap.placeDetails': 'Làm phong phú địa điểm', + 'admin.plugins.cap.search': 'Trả lời tìm kiếm', 'admin.plugins.cap.warnings': 'Gắn cờ vấn đề', 'admin.plugins.cap.mapLayers': 'Vẽ trên bản đồ', 'admin.plugins.cap.routing': 'Cung cấp định tuyến', 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/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index e0c831235..0dc5ac71b 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -18,17 +18,74 @@ 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', + 'roadtrip.limit.fillLabel': 'Đổ đến', + 'roadtrip.limit.fillFull': 'đầy', + 'roadtrip.limit.fillBadge': 'đổ đến {percent} %', + 'roadtrip.limit.wearNote': 'mất {percent} % do chai', + 'roadtrip.limit.rangeLabelCharge': 'Quãng đường mỗi lần sạc', + 'roadtrip.limit.sectionTrip': 'Chuyến đi này', + 'roadtrip.limit.sectionDriving': 'Lái xe', + 'roadtrip.limit.sectionVehicle': 'Phương tiện', + 'roadtrip.limit.tankLabel': 'Dung tích bình', + 'roadtrip.limit.batteryLabel': 'Pin', + 'roadtrip.limit.useLabel': 'Mức tiêu thụ', + 'roadtrip.limit.wearLabel': 'Chai pin', + 'roadtrip.limit.computed': 'Tính từ thông số xe', + 'roadtrip.limit.perFill': 'mỗi bình', + 'roadtrip.limit.perCharge': 'mỗi lần sạc', + 'roadtrip.limit.afterFill': 'Dừng đến {percent} %: {distance}', + 'roadtrip.limit.fullNote': 'Một lần dừng đổ đầy', + 'roadtrip.limit.rangeEmpty': 'Chưa có quãng đường', + 'roadtrip.limit.rangeEmptyHint': 'Nhập quãng đường, hoặc điền các ô để TREK tự tính.', + 'roadtrip.limit.specHint': 'Khi điền, các ô này quyết định quãng đường ở trên.', + 'roadtrip.limit.vehicleHint': 'Chọn loại xe để nhập thông số thay cho quãng đường.', 'roadtrip.limit.minutes': 'phút', 'roadtrip.limit.off': 'tắt', + 'roadtrip.limit.blockNote': 'Một khối = {distance}', + 'roadtrip.limit.echoOver': '{over} trong {days} ngày vượt giới hạn.', + 'roadtrip.limit.echoNone': 'Cả {days} ngày đều trong giới hạn.', + 'roadtrip.limit.echoDry': '{count} đoạn không có điểm dừng trong tầm với.', + 'roadtrip.limit.echoEmpty': 'Mở một chuyến road trip để thấy tác dụng.', + 'roadtrip.limit.specToggle': 'Tính từ thông số xe', 'roadtrip.limit.hint': 'Chỉ tính thời gian lái. Thời gian dừng và nghỉ đêm không được tính.', 'roadtrip.limit.legOver': 'vượt {time} so với chặng dài nhất', '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.line.section': 'Đường tuyến', + 'roadtrip.line.connect': 'Nối các ngày', + 'roadtrip.line.dayColors': 'Mỗi ngày một màu', + 'roadtrip.line.hint': 'Cũng tính đoạn giữa hai ngày và cộng vào ngày tới nơi.', + '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.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.dryElectric': 'Pin hết ở đây', + 'roadtrip.refuel.findElectric': 'Tìm trạm sạc', + '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', + 'roadtrip.refuel.addElectric': 'Thêm {name} làm điểm dừng sạc', + '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', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': 'Hiện chỉ có {name} — thả một điểm dừng vào đây', 'roadtrip.leg.driveText': '{distance} trong {time}', 'roadtrip.day.stopCount': '{count} điểm dừng', + 'roadtrip.day.fold': 'Thu gọn', + 'roadtrip.day.folded': 'Đã thu gọn', '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', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': 'Kéo để định hình lại lộ trình, chuột phải để xóa', 'roadtrip.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt', 'roadtrip.warn.overnight': 'Ngày hôm sau', + 'roadtrip.spill.title': 'Từ ngày {number}', + 'roadtrip.spill.departs': 'khởi hành {time}', 'roadtrip.summary.stops': 'Điểm dừng', 'roadtrip.stay.add': 'Dat thoi gian dung', 'roadtrip.stay.none': 'Khong dung lai', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/zh-TW/admin.ts index b32c7a3ac..fde0feb2a 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -376,6 +376,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '為「回憶」提供照片', 'admin.plugins.perm.hook:calendar-source': '為行事曆提供事件', 'admin.plugins.perm.hook:place-detail-provider': '為地點提供額外資訊(評論、評分、連結)', + 'admin.plugins.perm.hook:search-provider': '從自有索引回答地點搜尋,與 TREK 自身的結果並列顯示', 'admin.plugins.perm.hook:trip-warning-provider': '對行程發出驗證警告(顯示於規劃工具中)', 'admin.plugins.perm.hook:table-contributor': '向行程檢視(預訂、地點、日期)新增欄位與操作', 'admin.plugins.perm.hook:map-marker-provider': '向行程地圖新增標記(例如顯示預訂或興趣點)', @@ -519,6 +520,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供行事曆事件', 'admin.plugins.cap.placeDetails': '豐富地點', + 'admin.plugins.cap.search': '回答搜尋', 'admin.plugins.cap.warnings': '標記問題', 'admin.plugins.cap.mapLayers': '在地圖上繪製', 'admin.plugins.cap.routing': '提供路線規劃', 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-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index c2f3564ab..ee5178316 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -18,17 +18,74 @@ 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': '單箱續航', + 'roadtrip.limit.fillLabel': '加到', + 'roadtrip.limit.fillFull': '滿', + 'roadtrip.limit.fillBadge': '加到 {percent} %', + 'roadtrip.limit.wearNote': '衰減損失 {percent} %', + 'roadtrip.limit.rangeLabelCharge': '每次充電續航', + 'roadtrip.limit.sectionTrip': '本次行程', + 'roadtrip.limit.sectionDriving': '駕駛', + 'roadtrip.limit.sectionVehicle': '車輛', + 'roadtrip.limit.tankLabel': '油箱容量', + 'roadtrip.limit.batteryLabel': '電池容量', + 'roadtrip.limit.useLabel': '能耗', + 'roadtrip.limit.wearLabel': '電池衰減', + 'roadtrip.limit.computed': '根據車輛資料計算', + 'roadtrip.limit.perFill': '每箱油', + 'roadtrip.limit.perCharge': '每次充電', + 'roadtrip.limit.afterFill': '加到 {percent} % 可行駛 {distance}', + 'roadtrip.limit.fullNote': '每次停靠加滿', + 'roadtrip.limit.rangeEmpty': '未設定續航', + 'roadtrip.limit.rangeEmptyHint': '填寫續航里程,或填好下面的資料,TREK 會自己算。', + 'roadtrip.limit.specHint': '填寫後,上面的續航由這些資料決定。', + 'roadtrip.limit.vehicleHint': '選擇車輛類型後,可以填車輛資料代替續航里程。', 'roadtrip.limit.minutes': '分鐘', 'roadtrip.limit.off': '關閉', + 'roadtrip.limit.blockNote': '一格 = {distance}', + 'roadtrip.limit.echoOver': '{days} 天中有 {over} 天超出上限。', + 'roadtrip.limit.echoNone': '{days} 天都在上限之內。', + 'roadtrip.limit.echoDry': '{count} 個路段沒有可抵達的停留點。', + 'roadtrip.limit.echoEmpty': '開啟一次公路旅行即可看到效果。', + 'roadtrip.limit.specToggle': '依車輛資料推算', 'roadtrip.limit.hint': '僅計算駕駛時間,不含停留與過夜。', 'roadtrip.limit.legOver': '超出最長駕駛 {time}', 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.line.section': '路線線條', + 'roadtrip.line.connect': '連接各天', + 'roadtrip.line.dayColors': '每天一種顏色', + 'roadtrip.line.hint': '也會規劃兩天之間的路段,並計入抵達的那一天。', + '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.refuel.dry': '此處將耗盡油量', + 'roadtrip.refuel.after': '{distance} 後', + 'roadtrip.limit.vehicleLabel': '你開什麼車', + 'roadtrip.limit.vehicleAny': '兩者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '電動', + 'roadtrip.refuel.dryElectric': '電量將在此耗盡', + 'roadtrip.refuel.findElectric': '尋找充電站', + 'roadtrip.refuel.find': '尋找加油站', + 'roadtrip.refuel.again': '重試', + 'roadtrip.refuel.looking': '正在沿路線尋找…', + 'roadtrip.refuel.spare': '剩餘 {distance}', + 'roadtrip.refuel.add': '將 {name} 加入為加油停靠點', + 'roadtrip.refuel.addElectric': '將 {name} 新增為充電停留點', + 'roadtrip.refuel.none': '在可到達的路段上沒有找到。', + 'roadtrip.refuel.incomplete': '搜尋被截斷,因此這段路線沒有完全檢查。', + 'roadtrip.refuel.failed': '地點搜尋沒有回應。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠點拖到這裡', 'roadtrip.leg.driveText': '{distance},{time}', 'roadtrip.day.stopCount': '{count} 個停靠點', + 'roadtrip.day.fold': '摺疊', + 'roadtrip.day.folded': '已摺疊', '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': '停靠類型', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': '拖曳可改變路線,右鍵刪除', 'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘', 'roadtrip.warn.overnight': '次日', + 'roadtrip.spill.title': '來自第 {number} 天', + 'roadtrip.spill.departs': '{time} 出發', 'roadtrip.summary.stops': '停靠點', 'roadtrip.stay.add': '設定停留時間', 'roadtrip.stay.none': '不停留', @@ -120,6 +186,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/admin.ts b/shared/src/i18n/zh/admin.ts index 4c4b51438..a2cea8159 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -335,6 +335,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '为回忆提供照片', 'admin.plugins.perm.hook:calendar-source': '为日历提供事件', 'admin.plugins.perm.hook:place-detail-provider': '为地点补充额外信息(评论、评分、链接)', + 'admin.plugins.perm.hook:search-provider': '从自有索引回答地点搜索,与 TREK 自身的结果并列显示', 'admin.plugins.perm.hook:trip-warning-provider': '对行程发出校验警告(显示在规划器中)', 'admin.plugins.perm.hook:table-contributor': '向行程视图(预订、地点、日期)添加列和操作', 'admin.plugins.perm.hook:map-marker-provider': '向旅行地图添加标记(例如显示预订或兴趣点)', @@ -479,6 +480,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供日历事件', 'admin.plugins.cap.placeDetails': '丰富地点信息', + 'admin.plugins.cap.search': '回答搜索', 'admin.plugins.cap.warnings': '标记问题', 'admin.plugins.cap.mapLayers': '在地图上绘制', 'admin.plugins.cap.routing': '提供路线规划', 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': '你的评分', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 8c34fb6cd..f5f99635f 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -18,17 +18,74 @@ 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': '单箱续航', + 'roadtrip.limit.fillLabel': '加到', + 'roadtrip.limit.fillFull': '满', + 'roadtrip.limit.fillBadge': '加到 {percent} %', + 'roadtrip.limit.wearNote': '衰减损失 {percent} %', + 'roadtrip.limit.rangeLabelCharge': '每次充电续航', + 'roadtrip.limit.sectionTrip': '本次行程', + 'roadtrip.limit.sectionDriving': '驾驶', + 'roadtrip.limit.sectionVehicle': '车辆', + 'roadtrip.limit.tankLabel': '油箱容量', + 'roadtrip.limit.batteryLabel': '电池容量', + 'roadtrip.limit.useLabel': '能耗', + 'roadtrip.limit.wearLabel': '电池衰减', + 'roadtrip.limit.computed': '根据车辆数据计算', + 'roadtrip.limit.perFill': '每箱油', + 'roadtrip.limit.perCharge': '每次充电', + 'roadtrip.limit.afterFill': '加到 {percent} % 可行驶 {distance}', + 'roadtrip.limit.fullNote': '每次停靠加满', + 'roadtrip.limit.rangeEmpty': '未设置续航', + 'roadtrip.limit.rangeEmptyHint': '填写续航里程,或填好下面的数据,TREK 会自己算。', + 'roadtrip.limit.specHint': '填写后,上面的续航由这些数据决定。', + 'roadtrip.limit.vehicleHint': '选择车辆类型后,可以填车辆数据代替续航里程。', 'roadtrip.limit.minutes': '分钟', 'roadtrip.limit.off': '关闭', + 'roadtrip.limit.blockNote': '一格 = {distance}', + 'roadtrip.limit.echoOver': '{days} 天中有 {over} 天超出上限。', + 'roadtrip.limit.echoNone': '{days} 天都在上限之内。', + 'roadtrip.limit.echoDry': '{count} 个路段没有可到达的停留点。', + 'roadtrip.limit.echoEmpty': '打开一次公路旅行即可看到效果。', + 'roadtrip.limit.specToggle': '根据车辆数据推算', 'roadtrip.limit.hint': '仅计算驾驶时间,不含停留和过夜。', 'roadtrip.limit.legOver': '超出最长驾驶 {time}', 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未设置', + 'roadtrip.line.section': '路线线条', + 'roadtrip.line.connect': '连接各天', + 'roadtrip.line.dayColors': '每天一种颜色', + 'roadtrip.line.hint': '也会规划两天之间的路段,并计入抵达的那一天。', + '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.refuel.dry': '此处将耗尽油量', + 'roadtrip.refuel.after': '{distance} 后', + 'roadtrip.limit.vehicleLabel': '你开什么车', + 'roadtrip.limit.vehicleAny': '两者', + 'roadtrip.limit.vehicleCombustion': '燃油', + 'roadtrip.limit.vehicleElectric': '电动', + 'roadtrip.refuel.dryElectric': '电量将在此耗尽', + 'roadtrip.refuel.findElectric': '查找充电站', + 'roadtrip.refuel.find': '查找加油站', + 'roadtrip.refuel.again': '重试', + 'roadtrip.refuel.looking': '正在沿路线查找…', + 'roadtrip.refuel.spare': '剩余 {distance}', + 'roadtrip.refuel.add': '将 {name} 添加为加油停靠点', + 'roadtrip.refuel.addElectric': '将 {name} 添加为充电停留点', + 'roadtrip.refuel.none': '在可到达的路段上没有找到。', + 'roadtrip.refuel.incomplete': '搜索被截断,因此这段路线没有完全检查。', + 'roadtrip.refuel.failed': '地点搜索没有响应。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜', @@ -99,7 +156,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.quietDay.one': '目前只有 {name} — 把停靠点拖到这里', 'roadtrip.leg.driveText': '{distance},{time}', 'roadtrip.day.stopCount': '{count} 个停靠点', + 'roadtrip.day.fold': '折叠', + 'roadtrip.day.folded': '已折叠', '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': '停靠类型', @@ -112,6 +176,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.via.hint': '拖动可改变路线,右键删除', 'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟', 'roadtrip.warn.overnight': '次日', + 'roadtrip.spill.title': '来自第 {number} 天', + 'roadtrip.spill.departs': '{time} 出发', 'roadtrip.summary.stops': '停靠点', 'roadtrip.stay.add': '设置停留时间', 'roadtrip.stay.none': '不停留', @@ -120,6 +186,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。', 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/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), 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; 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; diff --git a/shared/src/plugin-permissions.ts b/shared/src/plugin-permissions.ts index 04c1dc5a8..90196d967 100644 --- a/shared/src/plugin-permissions.ts +++ b/shared/src/plugin-permissions.ts @@ -47,6 +47,7 @@ export const PLUGIN_PERMISSIONS: readonly string[] = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', @@ -74,6 +75,7 @@ export const PLUGIN_HOOK_PERMISSION: Readonly> = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 826c5373b..378ef549b 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -28,6 +28,8 @@ const FIXTURES: Record> = { 'day:updated': { day: { id: 2, date: '2026-06-11' } }, 'day:deleted': { dayId: 2 }, 'day:reordered': { orderedIds: [2, 1] }, + 'roadtripVia:changed': { dayId: 2, vias: [{ id: 9, lat: 53.5, lng: 9.9 }] }, + 'roadtripTrack:changed': { dayId: 2, track: null }, 'dayNote:created': { dayId: 2, note: { id: 5 } }, 'dayNote:updated': { dayId: 2, note: { id: 5 } }, 'dayNote:deleted': { noteId: 5, dayId: 2 }, @@ -138,11 +140,12 @@ const DRIFT_VARIANTS: Partial[]> }; describe('@trek/shared realtime event registry', () => { - it('WSEVT-REG-001: pins the authoritative inventory counts (69 trip + 32 user = 101)', () => { + it('WSEVT-REG-001: pins the authoritative inventory counts (71 trip + 32 user = 103)', () => { // 67th to 69th trip event: the three collab:link:* a shared link emits. - expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(69); + // 70th and 71st: the road trip's vias and tracks, which used to be written silently. + expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(71); expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32); - expect(TREK_WS_EVENT_NAMES).toHaveLength(101); + expect(TREK_WS_EVENT_NAMES).toHaveLength(103); }); it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => { diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index 378e17733..fae358992 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -92,6 +92,25 @@ export const TREK_WS_EVENTS = { payload: z.union([z.object({ orderedIds: idList }), z.object({ day: entity })]), }, + // ── Road trip ──────────────────────────────────────────────────────────── + // The points a drive is routed through, and the tracks a day follows. Broadcast in one + // event carrying the day's whole list rather than one event per point: a via has no + // identity anyone reads, the client keeps them per day anyway, and a drag is a rapid + // burst of writes whose only interesting state is the one that lands last. + // + // These used to be silent, on the reasoning that a via is how one person draws a route + // rather than a change to the itinerary everybody reads. That does not hold: two people + // planning a road trip see the same line on the same map, and a reshaped drive changes + // every arrival time after it. + 'roadtripVia:changed': { + scope: 'trip', + payload: z.object({ dayId: id, vias: z.array(entity) }), + }, + 'roadtripTrack:changed': { + scope: 'trip', + payload: z.object({ dayId: id, track: entity.nullable() }), + }, + // ── Day notes ──────────────────────────────────────────────────────────── 'dayNote:created': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) }, 'dayNote:updated': { scope: 'trip', payload: z.object({ dayId: id, note: entity }) }, 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; 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. diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 64d4c317c..af6c7253c 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -94,6 +94,49 @@ module.exports = { } ``` +## Answer place searches from your own index + +**Needs:** `hook:search-provider` (+ `http:outbound:` and a matching `egress` when +the index is somebody else's server) + +TREK searches its own place index and OpenStreetMap. This hook adds a third one, and +its hits are drawn into the same list the user is already looking at. + +`rating` is the reason to reach for it: open data carries no ratings at all, so a +question like "the best rated hotel around here" cannot be answered from OpenStreetMap. +Whatever your index knows, this is where it reaches the search list. + +```js +module.exports = { + hooks: { + searchProvider: { + async search({ query, limit, lang, near }, ctx) { + const res = await fetch( + `https://your-index.example/search?q=${encodeURIComponent(query)}&n=${limit}`, + ) + const { results } = await res.json() + return results.map(r => ({ + id: r.ref, // namespaced host-side to plugin:: + name: r.title, + lat: r.lat, + lng: r.lon, + address: r.address, + rating: r.stars, // 0..5, clamped host-side + website: r.url, // http/https only + })) + }, + }, + }, +} +``` + +Called for an explicit search, never per keystroke, so an index with rate limits is +safe here. You get two seconds to answer and at most 20 of your places are kept: a +person is waiting on the list, and the app stops waiting at two and a half seconds +whatever happens, so a provider that misses that window is left out of the list rather +than holding it up. `near` is the coordinate the search is biased toward, +and it is what tells the four places called "Hase-dera" apart — use it when you have it. + ## Raise validation warnings on a trip **Needs:** `hook:trip-warning-provider` diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index c57cf2c13..d25ae0fab 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -140,8 +140,9 @@ permissions still requires explicit re-consent). ## The plugin types - **integration** — background logic (jobs, routes) with no UI of its own. Every - provider hook is **live** — from placeDetailProvider and warningProvider through - photoProvider (Memories) and calendarSource — see [Provider hooks](#provider-hooks). + provider hook is **live** — from placeDetailProvider, searchProvider and + warningProvider through photoProvider (Memories) and calendarSource — see + [Provider hooks](#provider-hooks). - **page** — adds a nav entry that opens a full-page sandboxed iframe. - **widget** — adds a card to the dashboard (`sidebar` slot), a hero-bar overlay (`hero` slot), a panel inside the trip planner's **place-detail** view @@ -805,6 +806,7 @@ module.exports = definePlugin({ | Hook | Permission | Status | |---|---|---| | `placeDetailProvider.getDetails(placeId, ctx)` → `{ label, value?, url? }[]` | `hook:place-detail-provider` | **live** — shown in the place-detail panel; also `GET /api/place-details/:placeId` | +| `searchProvider.search(request, ctx)` → `SearchResultPlace[]` | `hook:search-provider` | **live** — answers place searches from an index TREK does not ship, drawn into the app's own search list beside the core results (#2221). `request` is `{query, limit, lang?, near?}`; each place is `{id?, name, lat, lng, address?, rating?, website?, phone?, category?, description?}`. `rating` is the field open data cannot answer — OpenStreetMap carries none — so it is what makes "the best rated one around here" answerable at all; it is clamped to 0..5. Coordinates are range-checked, strings capped, `website` must be http/https, and ids are namespaced to `plugin::` so they can never collide with an OSM one. Called for an explicit search, **not** per keystroke. Also `GET /api/plugin-search`, and to a connected assistant as the `search_places_via_plugins` MCP tool | | `warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | `hook:trip-warning-provider` | **live** — validation warnings shown as a non-blocking banner in the trip planner; also `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use` | | `tableContributor.getContributions(view, tripId, ctx)` → `TableContribution[]` | `hook:table-contributor` | **live** — host-rendered **columns/actions** keyed by `entityId` in the reservations, transports, places, day, costs, packing, files and todos views. A `column` is `{kind:'column', entityId, id, label, value?, url?, icon?, tone?}` (url is http/https/mailto only); an `action` is `{kind:'action', entityId, id, label, icon?, target}` where `target` opens your sandboxed frame (`{kind:'frame', sub}`) or calls a route (`{kind:'route', method, sub}`). All fields are bounded + normalized host-side; also `GET /api/view-contributions/:view/:tripId` | | `mapMarkerProvider.getMarkers(tripId, ctx)` → `MapMarkerContribution[]` | `hook:map-marker-provider` | **live** — bounded markers overlaid on the trip map (#587). Each is `{id, lat, lng, label?, popupText?, url?, icon?, tone?}`; coordinates are range-checked (−90..90 / −180..180), text length-capped, url http/https/mailto-only, count capped (≤200/plugin). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/map-markers/:tripId` | @@ -1476,6 +1478,7 @@ install (and by `trek-plugin validate` and registry CI, which check against the | `ws:broadcast:user` | `ctx.ws.broadcastToUser` | | `http:outbound` or `http:outbound:` | outbound HTTP to `egress[]` hosts | | `hook:place-detail-provider` | `hooks.placeDetailProvider` — extra place rows TREK renders (see [Provider hooks](#provider-hooks)) | +| `hook:search-provider` | `hooks.searchProvider` — answers place searches from your own index (see [Provider hooks](#provider-hooks)) | | `hook:trip-warning-provider` | `hooks.warningProvider` — validation warnings in the planner (see [Provider hooks](#provider-hooks)) | | `hook:table-contributor` | `hooks.tableContributor` — host-rendered columns/actions in the reservations, transports, places, day, costs, packing, files and todos views (see [Provider hooks](#provider-hooks)) | | `hook:map-marker-provider` | `hooks.mapMarkerProvider` — bounded markers on the trip map | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 25abf404e..a0ca01af0 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -58,6 +58,7 @@ ungranted capability is physically unreachable**, not just disallowed. See | `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. | | `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. | | `hook:place-detail-provider` | Contribute extra details (reviews, ratings, links) to a place via the `hooks.placeDetailProvider` provider hook | Implement `PlaceDetailProvider` in `hooks` on the plugin definition (not on `ctx`) — shown in the place-detail panel; also exposed at `GET /api/place-details/:placeId`. | +| `hook:search-provider` | Answer place searches from an index TREK does not ship, beside its own results | Implement `SearchProvider` in `hooks` on the plugin definition — hits are drawn into the app’s search list; also exposed at `GET /api/plugin-search` and as the `search_places_via_plugins` MCP tool. Only these results can carry a `rating`: open data has none. | | `hook:trip-warning-provider` | Raise validation warnings on a trip via the `hooks.warningProvider` provider hook | Implement `WarningProvider` in `hooks` on the plugin definition (not on `ctx`) — shown as a non-blocking banner in the planner; also exposed at `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use`. | | `hook:map-marker-provider` | Overlay bounded **markers** on the trip map via the `hooks.mapMarkerProvider` provider hook | Implement `MapMarkerProvider` in `hooks` — returns `{id, lat, lng, label?, popupText?, url?, icon?, tone?}[]` (#587 "show bookings on map"). **Declarative only** — plugin JS never runs on the map canvas. The host range-checks coordinates (−90..90 / −180..180), String-coerces + length-caps text, allowlists the popup url (http/https/mailto), and caps the marker count (≤200) per plugin; a failing provider is skipped. Exposed at `GET /api/map-markers/:tripId`. | | `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. |