From fccc55732b2107b7a87c592635ba953f94e3bed2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 11 Sep 2026 23:25:06 +0200 Subject: [PATCH] feat(roadtrip): plan and edit trips through MCP without a browser --- client/src/App.tsx | 2 + client/src/api/wsEventPolicy.ts | 1 + client/src/components/Roadtrip/corridor.ts | 344 +------- .../src/components/Roadtrip/dayWindow.test.ts | 298 ------- client/src/components/Roadtrip/dayWindow.ts | 285 +------ client/src/components/Roadtrip/nightSpill.ts | 215 +---- .../src/components/Roadtrip/roadtripModel.ts | 805 +----------------- .../components/Roadtrip/useRoadtripRoutes.ts | 487 +---------- .../src/components/Roadtrip/vehicleRange.ts | 172 +--- .../hooks/useRoadtripPreferencesSync.test.ts | 24 + .../src/hooks/useRoadtripPreferencesSync.ts | 16 + server/src/nest/places/places.mcp.ts | 34 +- .../contributions/plugin-route-normalize.ts | 136 +++ .../contributions/plugin-routes.controller.ts | 137 +-- .../nest/roadtrip/roadtrip-plan.service.ts | 242 ++++++ .../nest/roadtrip/roadtrip-planning.mcp.ts | 157 ++++ .../nest/roadtrip/roadtrip-preferences.mcp.ts | 53 ++ .../roadtrip/roadtrip-preferences.service.ts | 39 + .../nest/roadtrip/roadtrip-router.service.ts | 306 +++++++ server/src/nest/roadtrip/roadtrip.mcp.ts | 18 + server/src/nest/roadtrip/roadtrip.module.ts | 12 +- .../tests/unit/nest/roadtrip-planning.test.ts | 302 +++++++ .../tests/unit/nest/roadtrip-router.test.ts | 164 ++++ shared/package.json | 10 + shared/src/index.ts | 2 + shared/src/realtime/events.schema.spec.ts | 11 +- shared/src/realtime/events.schema.ts | 19 +- shared/src/roadtrip/accessSpur.ts | 15 + shared/src/roadtrip/assemble.ts | 253 ++++++ .../src/roadtrip/corridor.spec.ts | 302 ++++--- shared/src/roadtrip/corridor.ts | 226 +++++ shared/src/roadtrip/day-boundary.schema.ts | 14 +- shared/src/roadtrip/dayWindow.spec.ts | 367 ++++++++ shared/src/roadtrip/dayWindow.ts | 334 ++++++++ .../src/roadtrip/nightSpill.spec.ts | 205 +++-- shared/src/roadtrip/nightSpill.ts | 159 ++++ shared/src/roadtrip/planning-types.ts | 170 ++++ shared/src/roadtrip/planning.schema.spec.ts | 47 + shared/src/roadtrip/planning.schema.ts | 44 + shared/src/roadtrip/planning.ts | 8 + shared/src/roadtrip/preferences.schema.ts | 39 + shared/src/roadtrip/roadtrip.schema.ts | 39 +- .../src/roadtrip/roadtripModel.spec.ts | 604 +++++++------ shared/src/roadtrip/roadtripModel.ts | 448 ++++++++++ shared/src/roadtrip/routeRun.ts | 53 ++ shared/src/roadtrip/units.ts | 32 + .../src/roadtrip/vehicleRange.spec.ts | 125 +-- shared/src/roadtrip/vehicleRange.ts | 110 +++ shared/tsdown.config.ts | 6 +- wiki/MCP-Addon-Tools.md | 21 + wiki/Trip-Planner-Overview.md | 2 +- 51 files changed, 4545 insertions(+), 3369 deletions(-) delete mode 100644 client/src/components/Roadtrip/dayWindow.test.ts create mode 100644 client/src/hooks/useRoadtripPreferencesSync.test.ts create mode 100644 client/src/hooks/useRoadtripPreferencesSync.ts create mode 100644 server/src/nest/plugins/contributions/plugin-route-normalize.ts create mode 100644 server/src/nest/roadtrip/roadtrip-plan.service.ts create mode 100644 server/src/nest/roadtrip/roadtrip-planning.mcp.ts create mode 100644 server/src/nest/roadtrip/roadtrip-preferences.mcp.ts create mode 100644 server/src/nest/roadtrip/roadtrip-preferences.service.ts create mode 100644 server/src/nest/roadtrip/roadtrip-router.service.ts create mode 100644 server/tests/unit/nest/roadtrip-planning.test.ts create mode 100644 server/tests/unit/nest/roadtrip-router.test.ts create mode 100644 shared/src/roadtrip/accessSpur.ts create mode 100644 shared/src/roadtrip/assemble.ts rename client/src/components/Roadtrip/corridor.test.ts => shared/src/roadtrip/corridor.spec.ts (52%) create mode 100644 shared/src/roadtrip/corridor.ts create mode 100644 shared/src/roadtrip/dayWindow.spec.ts create mode 100644 shared/src/roadtrip/dayWindow.ts rename client/src/components/Roadtrip/nightSpill.test.ts => shared/src/roadtrip/nightSpill.spec.ts (61%) create mode 100644 shared/src/roadtrip/nightSpill.ts create mode 100644 shared/src/roadtrip/planning-types.ts create mode 100644 shared/src/roadtrip/planning.schema.spec.ts create mode 100644 shared/src/roadtrip/planning.schema.ts create mode 100644 shared/src/roadtrip/planning.ts create mode 100644 shared/src/roadtrip/preferences.schema.ts rename client/src/components/Roadtrip/roadtripModel.test.ts => shared/src/roadtrip/roadtripModel.spec.ts (71%) create mode 100644 shared/src/roadtrip/roadtripModel.ts create mode 100644 shared/src/roadtrip/routeRun.ts create mode 100644 shared/src/roadtrip/units.ts rename client/src/components/Roadtrip/vehicleRange.test.ts => shared/src/roadtrip/vehicleRange.spec.ts (74%) create mode 100644 shared/src/roadtrip/vehicleRange.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 509a943be..ceadffc3d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -24,6 +24,7 @@ import { tripRepo } from './repo/tripRepo' import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' +import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync' import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' import OfflineBanner from './components/Layout/OfflineBanner' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' @@ -364,6 +365,7 @@ export default function App() { const { settings } = useSettingsStore() useInAppNotificationListener() + useRoadtripPreferencesSync() useEffect(() => { if (isAuthenticated) { diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 46c22033a..1ad8c95ba 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -35,6 +35,7 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ // second copy of the same list to keep in step. 'roadtripVia:changed', 'roadtripTrack:changed', + 'roadtripPreferences:changed', // In-app notifications — hooks/useInAppNotificationListener 'notification:new', 'notification:updated', diff --git a/client/src/components/Roadtrip/corridor.ts b/client/src/components/Roadtrip/corridor.ts index 08886da6b..57a563526 100644 --- a/client/src/components/Roadtrip/corridor.ts +++ b/client/src/components/Roadtrip/corridor.ts @@ -1,342 +1,2 @@ -/** - * Corridor geometry — "what lies along this drive", without asking Overpass to think - * about a polyline. - * - * The naive form (`around:` with a hundred coordinates) is what every POI provider warns - * about and times out on. OsmAnd solves it the other way round and this follows it: ask - * for plain bounding boxes that cover the route, then measure each candidate's - * perpendicular distance to the line and throw away what is too far. The boxes are what - * the existing /api/maps/pois route already accepts, so no new server surface is needed. - * - * Everything here is pure: no React, no network, no store. - */ - -export interface LatLng { - lat: number - lng: number -} - -export interface Bbox { - south: number - west: number - north: number - east: number -} - -const EARTH_RADIUS_KM = 6371 -const KM_PER_DEG_LAT = 111.32 - -const toRad = (deg: number): number => (deg * Math.PI) / 180 - -/** Great-circle distance in kilometres. */ -export function haversineKm(a: LatLng, b: LatLng): number { - const dLat = toRad(b.lat - a.lat) - const dLng = toRad(b.lng - a.lng) - const lat1 = toRad(a.lat) - const lat2 = toRad(b.lat) - const h = - Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2) - return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))) -} - -/** - * Perpendicular distance from a point to a line segment, in kilometres. - * - * Works in a local equirectangular projection around the segment: longitudes shrink by - * cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north. - * Over the few kilometres a corridor spans, the error against a proper geodesic is far - * below the width the user picked. - */ -export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number { - return projectOnSegment(p, a, b).distanceKm -} - -/** - * Perpendicular distance to a segment plus where along it the foot of that perpendicular - * falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in - * the drive — taking the segment's midpoint instead makes every petrol station on a long - * straight report the same position. - */ -/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */ -const DEGENERATE_SEGMENT_KM2 = 1e-12 - -export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } { - const latScale = KM_PER_DEG_LAT - const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2)) - const ax = a.lng * lngScale - const ay = a.lat * latScale - const bx = b.lng * lngScale - const by = b.lat * latScale - const px = p.lng * lngScale - const py = p.lat * latScale - const dx = bx - ax - const dy = by - ay - const lenSq = dx * dx + dy * dy - // A segment whose two ends are the same point to within a millimetre has no - // direction to project onto, so measure straight to it. Written as a range because - // the ends came through a chain of floating point arithmetic and never land on 0. - if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 } - // Clamped so a point beside the segment's end measures to the end, not to the - // infinite line — otherwise a stop 200 km past the destination looks "on the way". - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq)) - const cx = ax + t * dx - const cy = ay + t * dy - return { distanceKm: Math.hypot(px - cx, py - cy), t } -} - -export interface CorridorHit { - /** Kilometres from the route line at its closest point. */ - offRouteKm: number - /** Kilometres travelled along the route before reaching that closest point. */ - alongKm: number -} - -/** - * Where a point sits relative to the route: how far off it is, and how far into the - * drive it comes. The second number is what puts "lunch" before "the hotel" in a list - * instead of sorting by name. - */ -export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null { - if (line.length < 2) return null - let best = Number.POSITIVE_INFINITY - let bestAlong = 0 - let travelled = 0 - for (let i = 0; i < line.length - 1; i++) { - const a = line[i] - const b = line[i + 1] - const segment = haversineKm(a, b) - const { distanceKm: d, t } = projectOnSegment(p, a, b) - if (d < best) { - best = d - bestAlong = travelled + t * segment - } - travelled += segment - } - return { offRouteKm: best, alongKm: bestAlong } -} - -/** - * Bounding boxes covering the route, each no larger than `maxSpanDeg`. - * - * The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not - * asked to scan a continent, which means a long drive has to be cut into boxes here - * rather than sent as one. Boxes are grown along the route until one would exceed the - * span, so a straight motorway needs far fewer requests than a mountain road. - */ -/** - * Splits a step that is longer than a box into even pieces. - * - * The simplifier is right to reduce a dead straight motorway stretch to its two ends, - * but tiling walks point to point: two ends 100 km apart produce a box at each end and - * nothing in between, so the middle of the drive would never be searched. - */ -function densify(line: LatLng[], maxStepDeg: number): LatLng[] { - if (line.length < 2) return line - const out: LatLng[] = [line[0]] - for (let i = 1; i < line.length; i++) { - const a = line[i - 1] - const b = line[i] - const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng)) - const steps = Math.max(1, Math.ceil(span / maxStepDeg)) - for (let s = 1; s <= steps; s++) { - out.push({ - lat: a.lat + ((b.lat - a.lat) * s) / steps, - lng: a.lng + ((b.lng - a.lng) * s) / steps, - }) - } - } - return out -} - -export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] { - if (line.length === 0) return [] - const padLat = widthKm / KM_PER_DEG_LAT - const tiles: Bbox[] = [] - let current: Bbox | null = null - - const padded = (b: Bbox): Bbox => { - // Longitude degrees shrink towards the poles, so the padding has to grow with - // latitude or the corridor is narrower than asked for in the north. - const midLat = (b.north + b.south) / 2 - const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat))) - return { - south: b.south - padLat, - north: b.north + padLat, - west: b.west - padLng, - east: b.east + padLng, - } - } - - // The point the open box last swallowed. When a box closes, the next one starts - // from it rather than from `p` alone, so the two overlap by exactly one step of - // the walk. Starting at `p` left the stretch between them covered only by the - // padding on either side — and the walk steps in up to maxSpanDeg/2 while the - // padding is widthKm, so at the offered widths a band of several kilometres of - // route fell into no box at all. Nothing downstream noticed: the panel counts - // tiles asked and tiles answered, so it reported a finished, uncapped, - // error-free search of a drive it had never fully looked at. "Find fuel along - // the route" is the whole feature. - let previous: LatLng | null = null - - for (const p of densify(line, maxSpanDeg / 2)) { - if (!current) { - current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng } - previous = p - continue - } - const next: Bbox = { - south: Math.min(current.south, p.lat), - north: Math.max(current.north, p.lat), - west: Math.min(current.west, p.lng), - east: Math.max(current.east, p.lng), - } - const grown = padded(next) - if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) { - tiles.push(padded(current)) - const from = previous ?? p - current = { - south: Math.min(from.lat, p.lat), - north: Math.max(from.lat, p.lat), - west: Math.min(from.lng, p.lng), - east: Math.max(from.lng, p.lng), - } - } else { - current = next - } - previous = p - } - if (current) tiles.push(padded(current)) - return tiles -} - -/** - * Thins a route down to the points that still describe it, so the corridor is built - * from tens of coordinates rather than the thousands a routed geometry carries. - * Ramer-Douglas-Peucker with the tolerance expressed in kilometres. - */ -export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] { - if (line.length < 3) return line.slice() - let maxDist = 0 - let index = 0 - const first = line[0] - const last = line[line.length - 1] - for (let i = 1; i < line.length - 1; i++) { - const d = distanceToSegmentKm(line[i], first, last) - if (d > maxDist) { - maxDist = d - index = i - } - } - if (maxDist <= toleranceKm) return [first, last] - const left = simplifyLine(line.slice(0, index + 1), toleranceKm) - 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, - } -} +export { haversineKm, distanceToSegmentKm, projectOnSegment, projectOntoRoute, corridorTiles, simplifyLine, pointAtMeters, lineMetres, sliceAtMeters, boxAround } from '@trek/shared/roadtrip' +export type { LatLng, Bbox, CorridorHit } from '@trek/shared/roadtrip' diff --git a/client/src/components/Roadtrip/dayWindow.test.ts b/client/src/components/Roadtrip/dayWindow.test.ts deleted file mode 100644 index 8983d04d7..000000000 --- a/client/src/components/Roadtrip/dayWindow.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow' -import type { RoadtripStop, RoutedLeg } from './useRoadtripRoutes' - -const hours = { start: 480, end: 1080 } -const labels = { start: 'Continue', end: 'End of day' } -const stop = (id: number, patch: Partial = {}): RoadtripStop => ({ - assignmentId: id, placeId: id, ownerDayId: 1, ownerIndex: id - 1, - lat: 0, lng: id - 1, name: `Stop ${id}`, time: null, dwellMinutes: 0, - legMode: null, incomingLegMode: null, stopType: null, ...patch, -}) -const day = (number: number, stops: RoadtripStop[] = []) => ({ - dayId: number, dayNumber: number, date: `2026-09-${String(number + 10).padStart(2, '0')}`, title: null, stops, -}) -const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({ - line: [[from.lat, from.lng], [to.lat, to.lng]], vias: [], - seg: { - from: [from.lat, from.lng], to: [to.lat, to.lng], mid: [0, (from.lng + to.lng) / 2], - distance: minutes * 1000, duration: minutes * 60, mode: 'driving', - distanceText: '', drivingText: '', walkingText: '', - }, -}) -const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) => - planDayWindow([day(1, stops), ...extra], hours, (a, b) => leg(minutes[stops.indexOf(a)] ?? 60, a, b), 'metric', labels) - -describe('daily travel window', () => { - it('pulls an unpinned visit back into day one and ends after its full stay', () => { - const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)] - const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b) - const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels) - expect(automatic.chains[0].stops).not.toContain(stops[1]) - const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels, - [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }]) - expect(manual.issue).toBeNull() - expect(manual.chains[0].stops).toContain(stops[1]) - expect(manual.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00', '20:30']) - expect(manual.chains[1].schedule.entries[0].arrival).toBe('08:00') - expect(stops[0].time).toBe('07:00') - }) - - it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', fraction => { - const stops = [stop(1), stop(2)] - const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels, - [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }]) - expect(manual.issue).toBeNull() - const end = manual.chains[0].stops[1] - expect(end.lng).toBeCloseTo(fraction) - expect(end.automaticNight).toMatchObject({ manual: true, position: fraction }) - const start = manual.chains[1].stops[0] - expect(manual.legFor(stops[0], end)!.seg.duration + manual.legFor(start, stops[1])!.seg.duration).toBeCloseTo(720 * 60) - }) - - it('does not pull a visit with a fixed time out of its stored day', () => { - const pinned = stop(2, { ownerDayId: 2, time: '12:00' }) - const manual = planDayWindow([day(1, [stop(1)]), day(2, [pinned])], hours, (a, b) => leg(60, a, b), 'metric', labels, - [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }]) - expect(manual.issue).toBe('conflict') - expect(pinned.time).toBe('12:00') - }) - - it('rejects crossed or disconnected manual boundaries', () => { - const stops = [stop(1), stop(2), stop(3)] - for (const boundaries of [ - [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }], - [{ day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }], - ]) expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe('conflict') - }) - - it('mixes an explicit stop end with automatic cuts on later drives', () => { - const visited = stop(2, { endDay: true, dwellMinutes: 60 }) - const planned = calculate([stop(1), visited, stop(3)], [120, 720], []) - expect(planned.issue).toBeNull() - expect(planned.chains.map(d => d.schedule.entries.map(e => e.arrival))).toEqual([ - ['08:00', '10:00', '11:00'], ['08:00', '18:00'], ['08:00', '10:00'], - ]) - expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng }) - expect(visited.endDay).toBe(true) - }) - - it('removing an explicit end restores the automatic schedule without adding a final night', () => { - const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], []) - expect(planned.chains).toHaveLength(1) - expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00', '11:00']) - }) - - it('keeps manual times when an explicit end would move their day', () => { - const pinned = stop(2, { time: '12:00' }) - const planned = calculate([stop(1, { endDay: true }), pinned], [60], []) - expect(planned.issue).toBe('conflict') - expect(pinned.time).toBe('12:00') - }) - - it('honors an earlier manual arrival on the following stored day', () => { - const first = stop(1, { endDay: true }) - const next = stop(2, { time: '07:00', ownerDayId: 2 }) - const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels) - expect(planned.issue).toBeNull() - expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00']) - expect(next.time).toBe('07:00') - }) - - it('requires two valid increasing HH:mm values', () => { - expect(dayWindow('08:00', '18:00')).toEqual(hours) - expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 }) - for (const pair of [[null, '18:00'], ['08:00', ''], ['8:00', '18:00'], ['24:00', '25:00'], ['18:00', '08:00'], ['08:00', '08:00'], [800, 1800]]) { - expect(dayWindow(...pair as [unknown, unknown])).toBeNull() - } - }) - - it('splits the drive at day end and resumes at the same point next morning', () => { - const first = stop(1) - const last = stop(2, { lng: 12 }) - const planned = calculate([first, last]) - expect(planned.issue).toBeNull() - const [today, tomorrow] = planned.chains - const end = today.stops[1] - const start = tomorrow.stops[0] - expect(today.schedule.entries[0].arrival).toBe('08:00') - expect(today.schedule.entries[1].arrival).toBe('18:00') - expect(end.lng).toBeCloseTo(10) - expect([start.lat, start.lng]).toEqual([end.lat, end.lng]) - expect(tomorrow.schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00']) - expect(tomorrow.stops[1]).toBe(last) - expect(tomorrow.spills[0]).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 }) - const before = planned.legFor(first, end)! - const after = planned.legFor(start, last)! - expect(before.seg.duration + after.seg.duration).toBe(720 * 60) - expect(before.seg.distance + after.seg.distance).toBe(720000) - expect(before.line[before.line.length - 1]).toEqual(after.line[0]) - expect(first.time).toBeNull() - expect(last.ownerDayId).toBe(1) - }) - - it('ends at the last visited place after its stay when the next drive will not fit', () => { - const first = stop(1) - const visited = stop(2, { dwellMinutes: 60 }) - const next = stop(3) - const route = [first, visited, next] - const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b) - const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels) - expect(planned.issue).toBeNull() - expect(planned.chains[0].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '16:00', '17:00']) - expect(planned.chains[0].stops[2]).toMatchObject({ lat: visited.lat, lng: visited.lng }) - expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '10:00']) - expect(planned.legFor(planned.chains[1].stops[0], next)?.seg.duration).toBe(7200) - expect(visited.dwellMinutes).toBe(60) - expect(next.ownerDayId).toBe(1) - const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels) - expect(alongRoute.chains[0].schedule.entries[2].arrival).toBe('18:00') - expect(alongRoute.chains[1].schedule.entries[1].arrival).toBe('09:00') - }) - - it('reaches a last place exactly at the cutoff and still respects manual times', () => { - const first = stop(1, { time: '07:00' }) - const next = stop(2) - const calculateAtStop = (minutes: number) => planDayWindow([day(1, [first, next])], { ...hours, endMode: 'stop' }, - (a, b) => leg(minutes, a, b), 'metric', labels) - expect(calculateAtStop(660).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00']) - next.time = '19:00' - expect(calculateAtStop(720).chains[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '19:00']) - expect(first.time).toBe('07:00') - }) - - it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => { - const planned = planDayWindow([day(1, [stop(1), stop(2)])], { ...hours, endMode: 'stop' }, - (a, b) => leg(720, a, b), 'metric', labels) - expect(planned.issue).toBe('legTooLong') - expect(planned.chains).toEqual([]) - expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' }) - expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours) - }) - - it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => { - const first = stop(1, { time: '07:00' }) - const planned = calculate([first, stop(2, { lng: 12 })]) - expect(planned.chains[0].schedule.entries[0]).toMatchObject({ arrival: '07:00', anchored: true }) - expect(planned.chains[0].stops[1].lng).toBeCloseTo(11) - expect(planned.chains[1].schedule.entries[1].arrival).toBe('09:00') - expect(first.time).toBe('07:00') - }) - - it('accounts for stays and recalculates the pause position without mutating places', () => { - const first = stop(1, { dwellMinutes: 120 }) - const destination = stop(2, { lng: 12 }) - const planned = calculate([first, destination]) - expect(planned.chains[0].stops[1].lng).toBeCloseTo(8) - expect(planned.chains[1].schedule.entries[1].arrival).toBe('12:00') - const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination]) - expect(shortened.chains[0].stops[1].lng).toBeCloseTo(10) - const shortDrive = calculate([first, destination], [120]) - expect(shortDrive.chains.flatMap(d => d.stops).some(s => s.automaticNight)).toBe(false) - expect(first.dwellMinutes).toBe(120) - }) - - it('does not move an arrival exactly at day end to tomorrow', () => { - const last = stop(2) - const planned = calculate([stop(1), last], [600]) - expect(planned.chains[0].stops).toHaveLength(2) - expect(planned.chains[0].schedule.entries[1].arrival).toBe('18:00') - expect(planned.chains[1].stops).toEqual([]) - }) - - it('continues across multiple nights and creates preview dates beyond the trip', () => { - const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], []) - expect(planned.chains.map(d => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13']) - expect(planned.chains[2].schedule.entries[1].arrival).toBe('18:00') - expect(planned.chains[2].dayId).toBeLessThan(0) - expect(planned.chains.flatMap(d => d.stops).filter(s => !s.automaticNight)).toHaveLength(2) - }) - - it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => { - const fixed = stop(2, { time: '10:00' }) - const impossible = calculate([stop(1), fixed], [180]) - expect(impossible.issue).toBe('conflict') - expect(impossible.chains).toEqual([]) - expect(fixed.time).toBe('10:00') - const possible = calculate([stop(1), fixed], [60]) - expect(possible.chains[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) - }) - - it('allows a manual late appointment to override the automatic end', () => { - const fixed = stop(2, { time: '19:00' }) - const planned = calculate([stop(1), fixed], [660]) - expect(planned.issue).toBeNull() - expect(planned.chains[0].schedule.entries[1].arrival).toBe('19:00') - expect(planned.chains[0].stops).toHaveLength(2) - }) - - it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => { - const a = stop(1) - const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' }) - const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels) - expect(planned.issue).toBeNull() - expect(planned.chains[1].schedule.entries.map(e => e.arrival)).toEqual(['06:00', '07:00']) - }) - - it('does not silently move a fixed appointment onto a later date after a previous spill', () => { - const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60]) - expect(planned.issue).toBe('conflict') - }) - - it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => { - const first = stop(1, { dwellMinutes: 660 }) - const planned = calculate([first, stop(2)], [60]) - expect(planned.chains[0].stops[1]).toMatchObject({ lat: first.lat, lng: first.lng }) - expect(planned.chains[0].schedule.entries[0].departure).toBe('18:00') - expect(planned.chains[1].schedule.entries[0].departure).toBe('09:00') - expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00') - expect(first.dwellMinutes).toBe(660) - }) - - it('does not place a night pause halfway through a ferry crossing', () => { - const a = stop(1) - const b = stop(2) - const ferry = leg(720, a, b) - ferry.seg.mode = 'ferry' - const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels) - expect(planned.chains[0].stops).toHaveLength(2) - expect(planned.chains[0].schedule.entries[1].arrival).toBe('20:00') - }) - - it('does not invent times or points for missing routes', () => { - const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels) - expect(planned.issue).toBe('incomplete') - expect(planned.chains).toEqual([]) - expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([]) - }) - - it('bounds extreme driving and stay durations', () => { - expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong') - expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong') - }) - - it('maps insertions on generated days back to real stored stops', () => { - const planned = calculate([stop(1), stop(2)]) - expect(roadtripInsertion(planned.chains[1], 0)).toEqual({ dayId: 1, position: 1 }) - expect(roadtripInsertion(planned.chains[1], 1)).toEqual({ dayId: 1, position: 1 }) - expect(roadtripInsertion({ stops: [] }, 0)).toBeNull() - expect(roadtripInsertion(planned.chains[1], 2)).toEqual({ dayId: 1, position: 2 }) - }) - - it('rounds fractional route minutes across the hour boundary', () => { - const planned = calculate([stop(1), stop(2)], [59.8]) - expect(planned.chains[0].schedule.entries[1].arrival).toBe('09:00') - }) - - it('keeps a zero-distance timed leg at its location when split', () => { - const a = stop(1) - const b = stop(2, { lng: a.lng }) - const timed = leg(720, a, b) - timed.seg.distance = 0 - timed.line = [] - const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels) - expect(planned.issue).toBeNull() - expect(planned.chains[1].stops[0]).toMatchObject({ lat: a.lat, lng: a.lng }) - expect(planned.chains[1].schedule.entries[1].arrival).toBe('10:00') - }) -}) diff --git a/client/src/components/Roadtrip/dayWindow.ts b/client/src/components/Roadtrip/dayWindow.ts index c8213ce03..fc592742c 100644 --- a/client/src/components/Roadtrip/dayWindow.ts +++ b/client/src/components/Roadtrip/dayWindow.ts @@ -1,283 +1,2 @@ -import { formatClock, parseClock } from './roadtripModel' -import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor' -import type { SpillChain } from './nightSpill' -import type { RoadtripDay, RoadtripStop, RoutedLeg } from './useRoadtripRoutes' -import { formatDistance } from '../../utils/units' -import { formatDurationShort } from './roadtripModel' -import type { DistanceUnit } from '../../types' -import type { RoadtripDayBoundary } from '@trek/shared' - -export interface DayWindow { - start: number - end: number - endMode?: 'route' | 'stop' -} - -export interface AutomaticNight { - phase: 'start' | 'end' - fromDayNumber: number - position?: number - manual?: boolean -} - -type PlanDay = Pick -type LegLookup = (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined - -export function dayWindow(start: unknown, end: unknown, endMode?: unknown): DayWindow | null { - const minutes = (clock: unknown): number | null => { - if (typeof clock !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(clock)) return null - return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3)) - } - const from = minutes(start) - const to = minutes(end) - return from !== null && to !== null && to > from ? { start: from, end: to, ...(endMode === 'stop' ? { endMode } as const : {}) } : null -} - -function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): RoutedLeg { - if (from === 0 && to === 1) return leg - const coordinates = leg.line.length ? leg.line : [leg.seg.from, leg.seg.to] - const spine = coordinates.map(([lat, lng]) => ({ lat, lng })) - const metres = lineMetres(spine) - const line = metres === 0 ? coordinates : sliceAtMeters(spine, from * metres, to * metres).map(p => [p.lat, p.lng] as [number, number]) - const distance = leg.seg.distance * (to - from) - const duration = leg.seg.duration * (to - from) - const durationText = formatDurationShort(duration) - const distanceText = formatDistance(distance / 1000, unit) - return { - seg: { - ...leg.seg, distance, duration, distanceText, durationText, - drivingText: `${distanceText} · ${durationText}`, - walkingText: `${distanceText} · ${durationText}`, - from: line[0], to: line[line.length - 1], mid: line[Math.floor(line.length / 2)], - }, - line, - vias: leg.vias.filter(v => { - const along = (projectOntoRoute(v, spine)?.alongKm ?? 0) * 1000 - return along >= from * metres && (to === 1 ? along <= metres : along < to * metres) - }), - } -} - -function stationary(stop: RoadtripStop): RoutedLeg { - const at: [number, number] = [stop.lat, stop.lng] - return { - line: [], vias: [], - seg: { from: at, to: at, mid: at, distance: 0, duration: 0, mode: 'driving', walkingText: '', drivingText: '', distanceText: '' }, - } -} - -export interface WindowPlan { - chains: SpillChain[] - legFor: LegLookup - issue: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null -} - -export function planDayWindow( - days: PlanDay[], window: DayWindow, lookup: LegLookup, unit: DistanceUnit, - labels: { start: string; end: string }, - boundaries: RoadtripDayBoundary[] = [], -): WindowPlan { - const ordered = [...days].sort((a, b) => a.dayNumber - b.dayNumber) - const stops = ordered.flatMap(day => day.stops.map(stop => ({ stop, day }))) - const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue }) - if (!stops.length) return { chains: [], legFor: lookup, issue: null } - const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i])) - const targets = new Map() - let lastTarget = -1 - for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) { - const from = positions.get(boundary.from_assignment_id) - const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id) - if (from === undefined || to !== null && to !== from + 1) return failed('conflict') - const target = from + (to === null ? 0 : boundary.fraction) - if (target <= lastTarget) return failed('conflict') - targets.set(boundary.day_number, target) - lastTarget = target - } - const legs = stops.slice(0, -1).map((s, i) => lookup(s.stop, stops[i + 1].stop)) - if (legs.some(l => !l || !Number.isFinite(l.seg.duration) || l.seg.duration < 0 || l.line.length < 2 && l.seg.distance > 0)) { - return failed('incomplete') - } - - const chains = new Map() - const pieces = new Map>() - const firstDay = stops[0].day.dayNumber - let number = firstDay - let clock = window.start - let issue: WindowPlan['issue'] = null - let position = 0 - - const chainAt = (n: number): SpillChain => { - const existing = chains.get(n) - if (existing) return existing - const stored = ordered.find(d => d.dayNumber === n) - const dated = ordered.find(d => d.date) - let date = stored?.date ?? null - if (!stored && dated?.date) { - const dayDate = new Date(`${dated.date}T12:00:00Z`) - dayDate.setUTCDate(dayDate.getUTCDate() + n - dated.dayNumber) - date = dayDate.toISOString().slice(0, 10) - } - const chain: SpillChain = { - dayId: stored?.dayId ?? -1000000000 - n, - dayNumber: n, date, title: stored?.title ?? null, - stops: [], schedule: { entries: [], warnings: [] }, spills: [], - } - chains.set(n, chain) - return chain - } - const putLeg = (from: RoadtripStop, to: RoadtripStop, leg: RoutedLeg) => { - if (!pieces.has(from)) pieces.set(from, new Map()) - pieces.get(from)!.set(to, leg) - } - const append = (stop: RoadtripStop, arrival: number, departure = arrival) => { - const chain = chainAt(number) - chain.stops.push(stop) - chain.schedule.entries.push({ - arrival: formatClock(Math.round(arrival)), departure: formatClock(Math.round(departure)), - anchored: !stop.automaticNight && stop.time !== null, - dayOffset: Math.floor(arrival / 1440), - }) - } - const night = (at: RoadtripStop, final = false): RoadtripStop => { - const chain = chainAt(number) - const last = chain.stops[chain.stops.length - 1] - const end: RoadtripStop = { - ...at, assignmentId: -2000000000 - number * 2, - name: labels.end, time: null, dwellMinutes: 0, stopType: null, fillPercent: null, - automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, - } - append(end, clock) - if (last) putLeg(last, end, stationary(at)) - if (final) return end - number += 1 - if (number - firstDay > 365) issue = 'tooLong' - clock = window.start - const start: RoadtripStop = { - ...end, assignmentId: end.assignmentId - 1, name: labels.start, - automaticNight: { ...end.automaticNight!, phase: 'start' }, - } - append(start, clock) - return start - } - - let previous: RoadtripStop | undefined - for (let i = 0; i < stops.length; i++) { - const { stop, day } = stops[i] - const pin = parseClock(stop.time) - if (previous && number < day.dayNumber && !targets.has(number)) { - if (window.endMode !== 'stop') clock = Math.max(clock, window.end) - previous = night(previous) - while (number < day.dayNumber && !issue) { - clock = window.end - previous = night(previous) - } - } else if (!previous) { - number = day.dayNumber - clock = pin ?? window.start - } - if (issue) return failed(issue) - if (pin !== null && day.dayNumber !== number) return failed('conflict') - - if (previous) { - const leg = legs[i - 1]! - const minutes = leg.seg.duration / 60 - if (pin !== null && chainAt(number).stops.every(s => s.automaticNight)) { - clock = Math.min(clock, pin - minutes) - if (clock < 0) return failed('conflict') - const startEntry = chainAt(number).schedule.entries[0] - startEntry.arrival = formatClock(Math.round(clock)) - startEntry.departure = formatClock(Math.round(clock)) - } - if (pin !== null && clock + minutes > pin + 1) return failed('conflict') - - let fraction = 0 - const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined) - if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) { - if (minutes > window.end - window.start) return failed('legTooLong') - previous = night(previous) - if (issue) return failed(issue) - } - while (fraction < 1) { - const target = targets.get(number) - if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict') - const manualCut = target !== undefined && target < i && target >= i - 1 + fraction - if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) break - const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock) - if (available === 0) { - previous = night(previous) - } else { - const until = Math.min(1, fraction + available / minutes) - const part = portion(leg, fraction, until, unit) - const at = part.line[part.line.length - 1] - const point = { ...previous, lat: at[0], lng: at[1] } - clock += available - position = i - 1 + until - const before = previous - previous = night(point) - const previousStops = chainAt(number - 1).stops - const ending = previousStops[previousStops.length - 1] - putLeg(before, ending, part) - fraction = until - } - if (issue) return failed(issue) - } - const remainder = portion(leg, fraction, 1, unit) - clock += minutes * (1 - fraction) - putLeg(previous, stop, remainder) - } - position = i - if (pin !== null && number !== day.dayNumber) return failed('conflict') - const target = targets.get(number) - if (target !== undefined && target < position) return failed('conflict') - if (pin !== null) clock = pin - append(stop, clock) - previous = stop - - let dwell = Math.max(0, stop.dwellMinutes ?? 0) - while (dwell > 0) { - const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock) - const spend = Math.min(dwell, remaining) - clock += spend - dwell -= spend - const entries = chainAt(number).schedule.entries - entries[entries.length - 1].departure = formatClock(Math.round(clock)) - if (dwell > 0) { - previous = night(previous) - if (issue) return failed(issue) - } - } - if (target === position || !targets.has(number) && stop.endDay && i < stops.length - 1) { - previous = night(previous, i === stops.length - 1) - if (issue) return failed(issue) - } - } - - if ([...targets.keys()].some(day => !chains.get(day)?.stops.some(stop => stop.automaticNight?.phase === 'end'))) return failed('conflict') - - for (const chain of chains.values()) { - for (let i = 0; i < chain.stops.length; i++) { - const stop = chain.stops[i] - if (stop.automaticNight) continue - const owner = ordered.find(d => d.dayId === stop.ownerDayId) - if (!owner || owner.dayNumber === chain.dayNumber) continue - const last = chain.spills[chain.spills.length - 1] - if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1 - else chain.spills.push({ - automatic: true, - at: i, count: 1, fromDayNumber: owner.dayNumber, departure: null, - leg: undefined, line: [], fromStop: undefined, - }) - } - } - for (const day of ordered) chainAt(day.dayNumber) - return { - chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber), - legFor: (from, to) => pieces.get(from)?.get(to), - issue: null, - } -} - -export function roadtripInsertion(day: Pick, index: number) { - const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)] - return stop ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } : null -} +export { dayWindow, planDayWindow, roadtripInsertion } from '@trek/shared/roadtrip' +export type { DayWindow, AutomaticNight, WindowPlan } from '@trek/shared/roadtrip' diff --git a/client/src/components/Roadtrip/nightSpill.ts b/client/src/components/Roadtrip/nightSpill.ts index bfe1dc56b..5457b9915 100644 --- a/client/src/components/Roadtrip/nightSpill.ts +++ b/client/src/components/Roadtrip/nightSpill.ts @@ -1,213 +1,2 @@ -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 { - automatic?: boolean - /** 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 -} +export { spillChains } from '@trek/shared/roadtrip' +export type { SpillMark, SpillChain } from '@trek/shared/roadtrip' diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index b9d408dd4..87da618f4 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -1,803 +1,2 @@ -/** - * Pure road-trip arithmetic — no React, no network, no store. Everything here is a - * function of the stops, the routed legs and the day's rules, so it can be unit tested - * 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. - * - * Every kind the corridor search can find, and the list is meant to stay that way: what - * you pick off "along the route" is by definition something you come across on the way, - * so it belongs inside the leg it was found on rather than among the places the trip is - * for. They are drawn on the dashed line with their own icon, carry no number, and are - * left out of every stop count — four places with a charger, a rest stop and a bakery - * between them is a four-stop day. - * - * Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the - * enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like - * a destination on one screen and a pause on the next. - */ -export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const - -/** - * What each kind of pause is coloured, on the rail, in the corridor list and on the map. - * - * Read as road signage rather than as a palette: a pump is petrol orange, a charger is - * the yellow of electricity, a rest area takes the blue every parking sign in Europe is - * printed in, a campsite is green. The last two are the colours the general place search - * already gives food and sights, so a restaurant found along the route looks like the - * restaurants found anywhere else. - * - * Deliberately literal and shared rather than tokenised: these mean the same thing in - * both themes the way a motorway sign does, and one definition is what stops the rail, - * the search result and the map pin from drifting into three different reds. - */ -export const SERVICE_COLORS: Record = { - fuel: '#E8590C', - charging: '#CA8A04', - rest_area: '#3B82F6', - campsite: '#16A34A', - restaurant: '#EF4444', - sights: '#EC4899', -} - -/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */ -export function serviceColor(stopType: string | null | undefined): string { - return SERVICE_COLORS[stopType ?? ''] ?? '#64748B' -} - -/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */ -export function isServiceStopType(stopType: string | null | undefined): boolean { - return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType) -} - -/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */ -export function formatDurationShort(seconds: number): string { - const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0 - const h = Math.floor(safe / 3600) - const m = Math.round((safe % 3600) / 60) - // 59.6 min must not print as "60 min"; carry it. - if (m === 60) return `${h + 1} h` - if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h` - return `${m} min` -} - -/** "09:45" → 585. Anything that is not a wall-clock time returns null. */ -export function parseClock(value: string | null | undefined): number | null { - if (!value) return null - const m = /^(\d{1,2}):(\d{2})/.exec(value.trim()) - if (!m) return null - const h = Number(m[1]) - const min = Number(m[2]) - if (h > 23 || min > 59) return null - return h * 60 + min -} - -/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */ -export function formatClock(minutes: number): string { - const day = 24 * 60 - const wrapped = ((minutes % day) + day) % day - const h = Math.floor(wrapped / 60) - const m = Math.round(wrapped % 60) - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` -} - -export interface ScheduleStop { - /** Wall-clock time the user pinned on this stop, if any. */ - anchor: string | null - /** Planned time spent here, in minutes. */ - dwellMinutes: number | null -} - -export interface ScheduleWarning { - /** - * Index of the stop the finding belongs to. - * - * For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves. - * Both findings are a running total that only becomes true on arrival: "125 km since - * the last fill-up" is what the tank reads when you get there, and the figure has to - * agree with the one anybody adds up off the drive bands above it. Anchored to the - * departure it read as the distance of the leg leaving that stop, which is a different - * number entirely and the one that made the badge look wrong. - */ - index: number - code: 'late' | 'overnight' | 'leg' | 'range' - /** Minutes the computed arrival misses the anchor by (code 'late'). */ - minutes?: number - /** Minutes this leg runs over the longest single drive allowed (code 'leg'). */ - overMinutes?: number - /** Kilometres driven since the last fill-up when the range ran out (code 'range'). */ - sinceKm?: number -} - -/** - * The two kinds of stop that put fuel or charge back. - * - * Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and - * TREK knows nothing about whether the restaurant has a charger in its car park. Reusing - * the service list here would quietly reset the range budget at every rest area and hide - * the one finding this feature exists to produce. - */ -export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const - -/** - * 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 ?? '') -} - -/** - * The three numbers a traveller can set. null means no limit, which is an explicit off - * 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. */ - dayMinutes: number | null - /** How far one tank or charge goes, in kilometres. */ - rangeKm: number | null -} - -/** - * A finding about the day as a whole. - * - * Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be - * read by every consumer that filters findings by stop, and one of them would eventually - * draw the day's finding on the first stop. - */ -export interface DayWarning { - code: 'dayDriving' - /** Driving time of the day in minutes, the same number the badge beside it shows. */ - minutes: number - limitMinutes: number -} - -export interface ScheduleEntry { - /** Computed arrival, or the anchor where the user pinned one. */ - arrival: string | null - departure: string | null - /** True when the time was pinned by the user rather than derived. */ - anchored: boolean - /** Days past the first stop, so a chain running past midnight stays readable. */ - dayOffset: number -} - -export interface Schedule { - entries: ScheduleEntry[] - warnings: ScheduleWarning[] -} - -/** - * Walks the chain forward: arrival = previous departure + driving time, departure = - * arrival + time spent at the stop. A stop the user pinned a time on does not move — - * it restarts the cascade from its own time, which is what makes a ferry, a museum - * ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way). - * - * `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is - * `undefined` and breaks the chain rather than inventing a duration. - */ -/** - * Where a stop's clock actually starts. - * - * A pinned time wins over whatever the drive before it worked out and restarts the - * cascade from there. When it sits before the arrival the drive implies, the plan does - * not fit: the caller is told by how much rather than the pinned time being moved. - */ -const DAY_MINUTES = 24 * 60 - -function resolveArrival( - anchor: number | null, - cursor: number | null, - dayOffset: number, -): { arrival: number | null; lateBy: number | null } { - if (anchor === null) return { arrival: cursor, lateBy: null } - // No drive has been timed yet, so there is nothing to be late against — but the - // day carried so far still applies, or a pinned stop after an untimed leg - // prints under a day-1 stop as though it happened first. - if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null } - // The occurrence of that wall-clock time nearest the cursor, not the one on the - // day the cascade has reached so far. `dayOffset` only advances once a computed - // arrival crosses midnight, and it is read before this stop's arrival is known — - // so when the stop that crosses midnight is the pinned one, its anchor was - // placed a whole day early and the plan was reported as 24 hours late while - // being exactly on time. Rounding also keeps the ordinary case: 08:00, a three - // hour leg, a 09:00 pin still picks the same day and is two hours late. - const k = Math.round((cursor - anchor) / DAY_MINUTES) - const anchorAt = anchor + k * DAY_MINUTES - return { - arrival: anchorAt, - lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null, - } -} - -export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { - 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 - - for (let i = 0; i < stops.length; i++) { - const stop = stops[i] - const anchor = parseClock(stop.anchor) - const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset) - if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }) - - 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 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(arrival + (stops[i].dwellMinutes ?? 0)), - anchored: anchored[i], - dayOffset: offset, - }) - } - - return { entries, warnings } -} - -/** - * Splits a day's stops into the stretches that can be asked for in one routing request: - * consecutive stops whose leg shares a travel mode. - * - * The router returns a leg for every consecutive pair of the waypoints it is handed, so - * a day travelled one way is a single request rather than one per leg — the difference - * between a road trip appearing at once and crawling in over ten seconds. A day that - * mixes walking and driving splits at the change, because one request carries one mode. - */ -export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] { - const runs: { stops: T[]; mode: string }[] = [] - let current: T[] = [] - let currentMode: string | null = null - - for (let i = 0; i < stops.length - 1; i++) { - const mode = modeOfLeg(stops[i], stops[i + 1]) - if (currentMode === null) { - current = [stops[i], stops[i + 1]] - currentMode = mode - } else if (mode === currentMode) { - current.push(stops[i + 1]) - } else { - runs.push({ stops: current, mode: currentMode }) - current = [stops[i], stops[i + 1]] - currentMode = mode - } - } - if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode }) - return runs -} - -/** Total driving seconds of the legs that actually routed. */ -export function sumLegSeconds(legSeconds: (number | undefined)[]): number { - return legSeconds.reduce((sum, s) => sum + (s ?? 0), 0) -} - -/** - * Where a place found along the drive belongs in the day's chain. - * - * Both numbers are distances travelled along the same routed line, so the comparison is - * "which stops has the car already passed when it reaches this one". A petrol station - * 120 km into the day goes after every stop nearer than that and before the rest, - * which is what makes adding one a single click rather than a drag afterwards. - * - * Clamped to sit inside the day: a corridor is wider than the road, so a hit can project - * slightly before the first stop or past the last, and neither "before where you set off" - * nor "after where you arrive" is a place a fuel stop can go. - */ -/** - * Which leg a point on the drive belongs to, given where each leg ends. - * - * Measured against the legs' own lengths rather than by projecting the stops as well: the - * router's leg distances are exact by construction, while two stops close together - * project onto each other's stretch and would put a charging halt on the wrong side of a - * 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. - * - * Reads the legs and nothing else. The clock cascade is not available to lean on: with no - * stop pinned to a time it produces no times at all, for any stop, so a limit expressed as - * an hour of the day would simply never fire. All three limits are therefore durations and - * kilometres, which exist as soon as a leg has routed. - * - * The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across - * midnight, because a tank does not empty overnight, and it keeps counting past a finding, - * because a warning is not a fill-up. Zeroing it there made the second figure wrong and - * every one after it: a day reading 245 km at one stop and 121 at the next claims the 121 - * is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with - * no fuel on it is not noise; it is the answer, and it stops the moment a fuel or - * charging stop is put in. - * - * An unrouted leg gives the budget up entirely rather than guessing, the same way the - * schedule abandons its cursor: a distance we do not have cannot be added to one we do. - */ -export function deriveDriveWarnings( - /** - * `mode` decides whether a leg counts at all. A day may legitimately mix - * modes — splitIntoRuns exists for exactly that — and a two hour walk to a - * viewpoint is not two hours of driving, nor are its kilometres on the tank. - * Counting them reported a hike as "over the longest drive allowed" and let a - * ferry crossing trip a range warning. Anything that is not a driving profile - * is skipped; an absent mode counts, because every leg was a drive before the - * field existed. - */ - legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[], - /** Whether the stop at each index refuels. One entry longer than `legs`. */ - refuelsAt: boolean[], - limits: DriveLimits, - /** Kilometres already on the tank when the day starts; null when that is unknown. */ - carryKm: number | null, - /** - * Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`. - * - * A property of the stop rather than of the traveller: a motorway rapid charger is - * worth about 80 % because the last fifth costs as long again, while the one at the - * hotel is worth all of it because the car stands there all night. One figure for the - * whole trip cannot say both, and the difference between them is a leg. - * - * Last and optional so the four-argument call, which is every caller that has no - * per-stop opinion, keeps meaning exactly what it did. - */ - fillAt: readonly (number | null | undefined)[] = [], -): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } { - const warnings: ScheduleWarning[] = [] - const emptyAt: DryPoint[] = [] - 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 = 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 - // Not a drive: neither its minutes nor its kilometres belong in findings - // about the driving. The budget is left exactly as it was — walking to a - // viewpoint and back does not use fuel, and it does not fill the tank - // either. - if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue - const seconds = leg?.duration - if (typeof seconds === 'number') { - totalSeconds += seconds - if (limits.legMinutes && seconds / 60 > limits.legMinutes) { - warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) }) - } - } - const metres = leg?.distance - 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 - // to keep counting until a fuel or charging stop actually puts one in. - 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 = 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, emptyAt } -} - -export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number { - if (!legEndMeters.length) return -1 - for (let i = 0; i < legEndMeters.length; i++) { - if (alongMeters < legEndMeters[i]) return i - } - return legEndMeters.length - 1 -} - -/** - * The places on a day somebody would plan a break around. - * - * Every stop, and the middle of every leg between two of them. The leg midpoints are the - * point of the whole thing: a break on a five-hour drive is planned in the middle of the - * drive, not at either end of it, and offering only the stops would mean asking "what is - * near Berlin" when the question is "what is near the halfway mark". - */ -export interface SectionAnchor { - kind: 'stop' | 'leg' - /** Index of the stop, or of the stop the leg leaves. */ - index: number - alongKm: number -} - -export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] { - const out: SectionAnchor[] = [] - for (let i = 0; i < stopsAlongKm.length; i++) { - out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] }) - if (i + 1 < stopsAlongKm.length) { - out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 }) - } - } - return out -} - -export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number { - if (stopsAlongKm.length < 2) return stopsAlongKm.length - let i = 0 - while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++ - return Math.min(Math.max(i, 1), stopsAlongKm.length - 1) -} - -/* ── Via re-anchoring ──────────────────────────────────────────────────────── - * - * A via records which stop it follows as a POSITION in the day's stop list, and that - * position is resolved fresh every time the day is routed. So the moment the list - * changes shape — a stop inserted, removed, reordered, pushed to another day — every - * anchor at or past the change means a different leg than it did when it was written. - * The visible symptom is the route quietly snapping back to the road the user had - * steered it away from, with the via handle still sitting on the map where they left it. - * - * Nothing about that is repairable after the fact: once the stops have moved there is no - * record of which leg a via was drawn for. So the correction has to be computed against - * the list as it stands and applied with the same change. - * - * These are pure index arithmetic on purpose. The one judgement that needs geometry — - * which half of a split leg a via belongs to — is asked of the caller, because only the - * caller has the routed line to measure against. - */ - -/** - * A via, reduced to what re-anchoring needs to know about it. - * - * The coordinates are part of it because the one decision that is not pure arithmetic — - * which half of a split leg a via belongs to — is answered by measuring where it sits on - * the road, and the caller is handed the whole via to measure. - */ -export interface AnchoredVia { - id: number - after_order_index: number - lat: number - lng: number -} - -/** Where one via ends up. */ -export interface ReanchoredVia { - id: number - after_order_index: number -} - -/** - * The corrected anchoring, and the vias that no longer have a leg at all. - * - * `vias` holds only what actually moved: sending the unchanged ones would be a larger - * write for no reason, and it keeps the diff readable when something goes wrong. - */ -export interface Reanchoring { - vias: ReanchoredVia[] - remove: number[] -} - -const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] } - -/** Only what moved, so an operation that changes nothing costs no request. */ -function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring { - const moved: ReanchoredVia[] = [] - const remove: number[] = [] - for (const via of vias) { - const next = at(via.after_order_index) - if (next === null) remove.push(via.id) - else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }) - } - return { vias: moved, remove } -} - -/** - * A stop was inserted at `position`, so everything from there on sits one place later. - * - * The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one - * before the new stop stays on the first half, one after it moves to the second. That is - * the whole reason a petrol station added in the middle of a reshaped drive used to undo - * the reshaping — the via went to the first half by accident of arithmetic, the second - * half got none, and the router took its own road again for the rest of the leg. - * - * `liesBeforeInsert` is asked only about vias on the split leg, and only when there is - * one: inserting at the very front or the very end splits nothing. - */ -export function reanchorAfterInsert( - vias: AnchoredVia[], - position: number, - liesBeforeInsert: (via: AnchoredVia) => boolean, -): Reanchoring { - if (!vias.length) return EMPTY_REANCHORING - const split = position - 1 - const moved: ReanchoredVia[] = [] - for (const via of vias) { - const i = via.after_order_index - let next = i - if (i === split) next = liesBeforeInsert(via) ? i : i + 1 - else if (i >= position) next = i + 1 - if (next !== i) moved.push({ id: via.id, after_order_index: next }) - } - return { vias: moved, remove: [] } -} - -/** - * A stop was taken out at `position`, so the legs either side of it become one. - * - * A via on either half keeps bending the same drive, so both end up on the merged leg — - * dropping them would throw away a decision the user made about a road that still exists. - * The exception is the ends: remove the first stop and the leg leaving it is gone, remove - * the last and the leg into it is gone, and a via pinned to one of those has nowhere left - * to sit. Those are reported for deletion rather than left pointing past the day. - * - * `stopCount` is the number of stops BEFORE the removal. - */ -export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring { - if (!vias.length) return EMPTY_REANCHORING - // Two stops become one: the day has no drive left, so no via has a leg. - if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) } - return collect(vias, i => { - if (position === 0) return i === 0 ? null : i - 1 - if (position === stopCount - 1) return i === position - 1 ? null : i - if (i === position) return position - 1 - return i > position ? i - 1 : i - }) -} - -/** - * A stop moved from `from` to `to` inside its own day. - * - * Composed from the two operations it actually is — taken out, put back — because that - * is the only reading under which the answer stays consistent with the other two. No - * geometry is asked for: a reorder changes which stops are neighbours, so the roads - * themselves are about to be different and there is nothing meaningful to measure a via - * against. Vias keep the earlier half of a split leg, which is the reading that leaves - * them where they were relative to the stop they were drawn after. - * - * `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as - * it looks AFTER the stop has been taken out. - */ -/** - * Re-pin a day's vias after its stops were reordered wholesale. - * - * The single-move helpers above take a from/to pair, which is what the rail - * hands them. The plan-mode drag hands a whole new ordering instead, and any - * permutation is possible — so this maps by the stop each anchor refers to - * rather than by arithmetic on the index. A via anchored after a stop stays - * anchored after that same stop, wherever it ended up. - * - * Both lists are in the rail's index space, i.e. only stops that can be routed. - * A via whose stop is gone, or which lands on the last stop and so has no leg - * left to bend, is reported for removal. - */ -export function reanchorByStopOrder( - vias: AnchoredVia[], - previousIds: number[], - nextIds: number[], -): Reanchoring { - if (!vias.length) return EMPTY_REANCHORING - const moved: ReanchoredVia[] = [] - const remove: number[] = [] - for (const via of vias) { - const stopId = previousIds[via.after_order_index] - const next = stopId === undefined ? -1 : nextIds.indexOf(stopId) - // Gone, or now the last stop of the day: either way there is no leg for it. - if (next === -1 || next >= nextIds.length - 1) { - remove.push(via.id) - continue - } - if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }) - } - return { vias: moved, remove } -} - -export function reanchorAfterReorder( - vias: AnchoredVia[], - from: number, - to: number, - stopCount: number, -): Reanchoring { - if (!vias.length || from === to) return EMPTY_REANCHORING - // A reorder is modelled as remove-then-insert, and the removal half refuses to - // treat a two-stop day as still having a drive: below three stops it reports - // every via for deletion, which is right when a stop really goes away and - // catastrophic here. Nothing is removed by a reorder — swapping the only two - // stops leaves leg 0 exactly where it was, merely driven the other way round — - // so the anchors are already correct and there is nothing to re-pin. - // - // Two stops is the shape "follow this track" produces: it lays up to nine vias - // on the single leg between one pair, and one drag in the rail deleted all of - // them, with no prompt and no undo. - if (stopCount <= 2) return EMPTY_REANCHORING - const afterRemove = reanchorAfterRemove(vias, from, stopCount) - const dropped = new Set(afterRemove.remove) - const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const)) - // The list the insert acts on is the one the removal left behind, so each via is - // carried through at whatever index it holds by then. - const shifted: AnchoredVia[] = vias - .filter(v => !dropped.has(v.id)) - .map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index })) - const afterInsert = reanchorAfterInsert(shifted, to, () => true) - const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const)) - - const result: ReanchoredVia[] = [] - for (const via of vias) { - if (dropped.has(via.id)) continue - const mid = movedTo.get(via.id) ?? via.after_order_index - const end = finalIndex.get(via.id) ?? mid - if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end }) - } - return { vias: result, remove: afterRemove.remove } -} - -/** Whether a re-anchoring has anything to write at all. */ -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(',') -} +export { SERVICE_STOP_TYPES, SERVICE_COLORS, serviceColor, isServiceStopType, formatDurationShort, parseClock, formatClock, REFUELLING_STOP_TYPES, refuelStopTypeFor, refuelsRange, computeSchedule, splitIntoRuns, sumLegSeconds, deriveDriveWarnings, legIndexForAlong, sectionAnchors, insertIndexForAlong, reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, reanchorAfterReorder, isEmptyReanchoring, parseAvoid, AVOIDABLE, serializeAvoid } from '@trek/shared/roadtrip' +export type { ScheduleStop, ScheduleWarning, VehicleKind, DriveLimits, DayWarning, ScheduleEntry, Schedule, DryPoint, SectionAnchor, AnchoredVia, ReanchoredVia, Reanchoring } from '@trek/shared/roadtrip' diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 64e6d09be..b263ec255 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -1,204 +1,17 @@ +import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip' import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' -import { - 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 { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel' +import { spillChains } from './nightSpill' import { useSettingsStore } from '../../store/settingsStore' import { useVehicleRange } from './useVehicleRange' -import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' -import { spurFor } from './accessSpur' +import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, SnappedWaypoint } from '../../types' import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared' -import { dayWindow, planDayWindow, type AutomaticNight, type WindowPlan } from './dayWindow' -import type { DayBoundaryLeg } from './dayBoundaryPath' +import { dayWindow } from './dayWindow' import { useTranslation } from '../../i18n/TranslationContext' -import { computeSchedule } from './roadtripModel' -/** One stop on a day's drive — an assignment whose place actually has coordinates. */ -export interface RoadtripStop { - automaticNight?: AutomaticNight - 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 - lng: number - /** Planned time on the assignment, or the place's own time as the fallback the API already resolves. */ - time: string | null - /** How long the visit is planned to take. `places.duration_minutes` has carried this for years. */ - dwellMinutes: number | null - endDay?: boolean - /** Mode of the leg LEAVING this stop; null inherits the day default. */ - legMode: string | null - 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. - * - * null means either not routed yet or close enough that nobody would call it a gap. - */ - offRoadMeters?: number | null -} - -export interface RoadtripDay { - automaticSchedule?: boolean - dayId: number - dayNumber: number - date: string | null - title: string | null - stops: RoadtripStop[] - /** Leg i connects stops[i] → stops[i+1]. Shorter than `stops` by one once every leg resolved. */ - legs: (RouteSegment | undefined)[] - /** Arrival and departure per stop, walked forward from the day's first pinned time. */ - schedule: Schedule - /** - * Halts a routing plugin placed on each leg, indexed alongside `legs`. - * - * Read-only and never written back: a plugin halt that became a via would go out as a - * waypoint on the next run, and the plugin would then optimise around its own charging - * 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 - * Berlin the straight line runs across open country while the motorway swings north of - * it, so a corridor built from the stops finds the wrong side of the map. - */ - geometry: [number, number][] - distance: number - duration: number - /** - * Findings about the driving itself, per leg: too long behind the wheel, the tank - * running out before the next fuel stop. Separate from `schedule.warnings`, which are - * findings about a stop, because these belong to no stop and because `Schedule` is - * pinned shape-for-shape by its own tests. - */ - 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]] - meters: number - /** So the rail and the map can point at the same one. */ - stopKey: string -} - -/** A day the rail does not draw a drive for, but that a stop can still be moved onto. */ -export interface QuietDay { - dayId: number - dayNumber: number - date: string | null - title: string | null - /** Usually none or one — two would have made it a drive. */ - stops: RoadtripStop[] -} - -export interface RoadtripRoutes { - boundaryPath?: DayBoundaryLeg[] - validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue'] - dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null - days: RoadtripDay[] - /** - * Days with fewer than two stops, in trip order. - * - * They carry no drive, so they are not part of `days` and nothing is routed for them — - * but a stop has to be able to move onto one, and a day that is not on screen cannot be - * dropped on. Listed separately rather than folded into `days` so the totals and the - * routing keep meaning exactly what they meant before. - */ - 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. - * - * Drawn dashed, the way Google draws the walk from the kerb to the door, so a stop well - * off the road reads as "the drive ends here and the rest is not driving" instead of - * looking like a route that cuts across open country. - */ - accessLines: AccessSpur[] - /** Every plugin halt of the trip, flat, which is the shape the map draws. */ - vias: RouteVia[] - /** The routed legs themselves, so the map can label each connector. */ - segments: RouteSegment[] - totalDistance: number - totalDuration: number - totalStops: number - /** True while legs are still coming in — the numbers below are a partial sum until then. */ - loading: boolean -} +export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip' /** * Gap between two routing requests. The public OSRM hosts TREK ships with state one @@ -223,35 +36,6 @@ const sleep = (ms: number, signal: AbortSignal): Promise => signal.addEventListener('abort', done, { once: true }) }) -/** - * A leg that routed: the numbers for the rail plus the geometry the map draws. - * - * The two optional fields are filled by their own features and are declared here rather - * than added later, because this shape is written in one place and read in another and - * three separate changes to the same two lines is three merge conflicts. - */ -export interface RoutedLeg { - seg: RouteSegment - line: [number, number][] - /** - * Where the router actually put each end of this leg, and how far that is from the - * place we asked for. OSRM snaps every waypoint to the nearest road with no distance - * limit and reports both in every response; a place set well off the road otherwise - * looks like it is on the route when the drive really starts hundreds of metres away. - */ - snapped?: { from: SnappedPoint | null; to: SnappedPoint | null } - /** Halts a routing plugin placed on this leg, in the order they are driven through. */ - vias: RouteVia[] -} - -/** A waypoint as the router resolved it, beside the coordinate that was asked for. */ -export interface SnappedPoint { - lat: number - lng: number - /** Straight-line metres from the requested coordinate to the road it snapped to. */ - offRoadMeters: number -} - /** * What makes a stop the same stop as far as routing is concerned. `planKey` is built from * these, and so are the keys the routed legs are filed under, so the two can never drift @@ -539,70 +323,7 @@ export function useRoadtripRoutes( if (s) daySnaps[stopKey(stop)] = s }) } - // Where a plugin's halts sit along this run, measured once. Only the ones - // that say something get projected — a bare coordinate has nothing to show - // in the rail, and projection is not free. - const spine = r.coordinates.map(([la, ln]) => ({ lat: la, lng: ln })) - const viaAlong = (r.vias ?? []) - .filter(v => v.label || v.dwellSeconds != null) - .map(v => ({ via: v, alongMeters: (projectOntoRoute({ lat: v.lat, lng: v.lng }, spine)?.alongKm ?? 0) * 1000 })) - - // Fold the router's per-waypoint legs back onto the stop pairs: a stop pair - // with a via between it comes back as two legs, and the rail shows one. - // A running metre count comes with it, so each halt lands on the leg it is - // actually driven on rather than on the nearest stop, which two towns close - // 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] - const to = run[i + 1] - if (!from || !to) continue - const parts = r.legs.slice(stopAt[i], stopAt[i + 1]) - if (!parts.length) continue - const merged = parts.length === 1 ? parts[0] : { - ...parts[0], - 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) - 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) - if (idx >= 0) dayLegs[legKeys[idx]].vias.push(via) - } + Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode)) return } catch (err) { // Almost always a rate limit on the shared routing host. Back off and try @@ -775,195 +496,7 @@ export function useRoadtripRoutes( // 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 storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)] - const timed = window ? planDayWindow( - [...plan, ...quietDays], window, storedLegFor, distanceUnit, - { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, - boundaries, - ) : null - const automaticSchedule = !!timed && timed.issue === null - const displayChains = automaticSchedule ? timed.chains : timed?.issue - ? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length).map(d => ({ - ...d, spills: [], schedule: computeSchedule( - d.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), - d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1])?.seg.duration), - ), - })) - : chains - const legFor = automaticSchedule ? timed.legFor : storedLegFor - - 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. `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 - /** 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 displayChains) { - 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 && !automaticSchedule) { - 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); lineDays.push(chain.dayNumber) } - segments.push(leg.seg) - } - // 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) - + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0) - const schedule = chain.schedule - const legVias = routed.map(l => l?.vias ?? []) - const stops = chain.stops.map(s => { - const snap = s.automaticNight ? undefined : 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, vehicleKind)), - limits, - 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({ - automaticSchedule, - 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 || d.stops.some(s => s.automaticNight)) - const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap(day => day.stops) - const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { - const to = originalStops[position + 1] - const leg = storedLegFor(from, to) - return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0 - ? [{ from, to, position, line: leg.line }] : [] - }) - return { - boundaryPath, - validateBoundaries: (next: RoadtripDayBoundary[]) => window ? planDayWindow([...plan, ...quietDays], window, storedLegFor, distanceUnit, - { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, next).issue : 'conflict', - dayWindowIssue: timed?.issue ?? null, - days: drives, - lines, - lineDays, - segments, - accessLines, - vias: out.flatMap(d => d.legVias.flat()), - 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: drives.reduce((s, d) => s + d.stops.filter(st => !st.automaticNight && !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 && !d.stops.some(s => s.automaticNight)) - .map(d => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), - loading, - } - }, [plan, quietDays, window, distanceUnit, t, chains, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries]) + return useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries, + labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') }, + }), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries]) } diff --git a/client/src/components/Roadtrip/vehicleRange.ts b/client/src/components/Roadtrip/vehicleRange.ts index cda588664..e5b348a01 100644 --- a/client/src/components/Roadtrip/vehicleRange.ts +++ b/client/src/components/Roadtrip/vehicleRange.ts @@ -1,170 +1,2 @@ -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) -} +export { rangeFromSpec, effectiveRangeKm, specUnit, showSpec, storeSpec } from '@trek/shared/roadtrip' +export type { VehicleSpec, SpecKey } from '@trek/shared/roadtrip' diff --git a/client/src/hooks/useRoadtripPreferencesSync.test.ts b/client/src/hooks/useRoadtripPreferencesSync.test.ts new file mode 100644 index 000000000..a19834ab9 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.test.ts @@ -0,0 +1,24 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useRoadtripPreferencesSync } from './useRoadtripPreferencesSync' +import { useSettingsStore } from '../store/settingsStore' +import { addListener, removeListener } from '../api/websocket' + +vi.mock('../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() })) + +describe('Roadtrip settings from MCP', () => { + it('merges valid user events, refuses unrelated settings and unsubscribes', () => { + const before = useSettingsStore.getState().settings + const hook = renderHook(() => useRoadtripPreferencesSync()) + const calls = vi.mocked(addListener).mock.calls + const listener = calls[calls.length - 1][0] + act(() => listener({ type: 'roadtripPreferences:changed', preferences: { roadtrip_day_start: '07:00' } })) + expect(useSettingsStore.getState().settings.roadtrip_day_start).toBe('07:00') + expect(useSettingsStore.getState().settings.distance_unit).toBe(before.distance_unit) + act(() => listener({ type: 'roadtripPreferences:changed', preferences: { roadtrip_day_start: '99:00' } })) + expect(useSettingsStore.getState().settings.roadtrip_day_start).toBe('07:00') + hook.unmount() + expect(removeListener).toHaveBeenCalledWith(listener) + useSettingsStore.setState({ settings: before }) + }) +}) diff --git a/client/src/hooks/useRoadtripPreferencesSync.ts b/client/src/hooks/useRoadtripPreferencesSync.ts new file mode 100644 index 000000000..648b06376 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react' +import { roadtripPreferencesSchema } from '@trek/shared' +import { addListener, removeListener } from '../api/websocket' +import { useSettingsStore } from '../store/settingsStore' + +export function useRoadtripPreferencesSync() { + useEffect(() => { + const listener = (event: Record) => { + if (event.type !== 'roadtripPreferences:changed') return + const parsed = roadtripPreferencesSchema.safeParse(event.preferences) + if (parsed.success) useSettingsStore.setState(state => ({ settings: { ...state.settings, ...parsed.data } })) + } + addListener(listener) + return () => removeListener(listener) + }, []) +} diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index e747bce78..9356a5888 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -11,6 +11,8 @@ import { placeImportListRequestSchema, placeWebsiteSchema, roadtripStopTypeSchema, + roadtripGpxImportSchema, + type RoadtripGpxImport, type RoadtripStopType, } from '@trek/shared'; import { z } from 'zod'; @@ -168,9 +170,9 @@ export class PlacesMcp { category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), price: z.number().optional(), currency: z.string().length(3).optional(), - place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'), - end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'), - duration_minutes: z.number().int().positive().optional(), + place_time: z.string().max(50).nullable().optional().describe('Scheduled time (e.g. "09:00"); null clears it'), + end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"); null clears it'), + duration_minutes: z.number().int().nonnegative().nullable().optional().describe('Stay duration in minutes; null clears it'), notes: z.string().max(2000).optional(), website: placeWebsiteSchema.optional(), phone: z.string().max(50).optional(), @@ -188,8 +190,8 @@ export class PlacesMcp { 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, 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; + address?: string; category_id?: number; price?: number; currency?: string; place_time?: string | null; + end_time?: string | null; duration_minutes?: number | null; 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; @@ -354,6 +356,28 @@ export class PlacesMcp { return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); } + @Tool({ + name: 'import_trip_gpx', + description: 'Import GPX waypoints, routes and tracks as places in a trip. Uses the same importer as the file upload. Imported tracks retain their geometry and can be followed with add_route_vias. Does not assign places to days. Use list_places for their ids and geometry, then assign_place_to_day and the Roadtrip tools to plan visits. Existing importer deduplication also applies.', + inputSchema: roadtripGpxImportSchema.shape, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'places', mode: 'write' }, + }) + async importGpx(input: RoadtripGpxImport, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.'); + try { + const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name }); + if (!imported) return errorResult('No matching places found in GPX.'); + for (const place of imported.places) this.guards.safeBroadcast(input.tripId, 'place:created', { place }); + return ok(imported); + } catch { + return errorResult('Could not import GPX. Check the XML and coordinates.'); + } + } + @Tool({ name: 'export_trip_gpx', description: 'Export a trip as GPX text: its places as waypoints, any imported routes as tracks, and each planned day as a route in visiting order. This is the format handhelds and offline map apps (Organic Maps, OsmAnd, Garmin) read. Prefer export_trip_ics when the user wants the itinerary in a calendar instead.', diff --git a/server/src/nest/plugins/contributions/plugin-route-normalize.ts b/server/src/nest/plugins/contributions/plugin-route-normalize.ts new file mode 100644 index 000000000..9963e551e --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-route-normalize.ts @@ -0,0 +1,136 @@ +import { stripEmoji } from '../text-sanitize'; + +import type Database from 'better-sqlite3'; + +type Tone = 'default' | 'success' | 'warn' | 'danger'; + +interface RouteWaypointIn { + lat: number; + lng: number; + name?: string; + placeId?: number; +} + +interface RouteLegOut { + distance: number; + duration: number; + note?: string; +} + +interface RouteViaOut { + lat: number; + lng: number; + label?: string; + tone: Tone; + dwellSeconds?: number; +} + +export interface PluginRouteOut { + pluginId: string; + profile: string; + coordinates: Array<[number, number]>; + distance: number; + duration: number; + legs: RouteLegOut[]; + viaPoints: RouteViaOut[]; +} + +const TONES: ReadonlySet = new Set(['default', 'success', 'warn', 'danger']); +export const PROFILE_RE = /^[a-z][a-z0-9-]{0,23}$/; +const MAX_WAYPOINTS = 30; +const MAX_COORDINATES = 10_000; // vertex budget for the returned geometry +const MAX_VIAS = 40; +const MAX_DWELL_S = 86_400; // a "stop" longer than a day is nonsense data +const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); + +function validCoord(lat: unknown, lng: unknown): boolean { + const a = Number(lat); + const b = Number(lng); + return Number.isFinite(a) && Number.isFinite(b) && a >= -90 && a <= 90 && b >= -180 && b <= 180; +} + +export function readWaypoints(raw: unknown): RouteWaypointIn[] | null { + if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_WAYPOINTS) return null; + const out: RouteWaypointIn[] = []; + for (const w of raw as Array>) { + if (!w || typeof w !== 'object' || !validCoord(w.lat, w.lng)) return null; + const name = w.name != null ? cap(w.name, 120) : undefined; + const placeId = Number.isInteger(w.placeId) ? (w.placeId as number) : undefined; + out.push({ + lat: Number(w.lat), + lng: Number(w.lng), + ...(name ? { name } : {}), + ...(placeId !== undefined ? { placeId } : {}), + }); + } + return out; +} + +function nonNeg(v: unknown): number | null { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : null; +} + +export function normalize( + pluginId: string, + profile: string, + waypointCount: number, + raw: unknown, +): PluginRouteOut | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + + if (!Array.isArray(r.coordinates) || r.coordinates.length < 2 || r.coordinates.length > MAX_COORDINATES) return null; + const coordinates: Array<[number, number]> = []; + for (const c of r.coordinates) { + if (!Array.isArray(c) || !validCoord(c[0], c[1])) return null; + coordinates.push([Number(c[0]), Number(c[1])]); + } + + const distance = nonNeg(r.distance); + const duration = nonNeg(r.duration); + if (distance == null || duration == null) return null; + if (!Array.isArray(r.legs) || r.legs.length !== waypointCount - 1) return null; + const legs: RouteLegOut[] = []; + for (const l of r.legs as Array>) { + if (!l || typeof l !== 'object') return null; + const legDistance = nonNeg(l.distance); + const legDuration = nonNeg(l.duration); + if (legDistance == null || legDuration == null) return null; + const note = l.note != null ? cap(l.note, 120) : undefined; + legs.push({ distance: legDistance, duration: legDuration, ...(note ? { note } : {}) }); + } + const viaPoints: RouteViaOut[] = []; + if (Array.isArray(r.viaPoints)) { + for (const v of (r.viaPoints as Array>).slice(0, MAX_VIAS * 4)) { + if (viaPoints.length >= MAX_VIAS) break; + if (!v || typeof v !== 'object' || !validCoord(v.lat, v.lng)) continue; + const dwell = Number(v.dwellSeconds); + const label = v.label != null ? cap(v.label, 80) : undefined; + viaPoints.push({ + lat: Number(v.lat), + lng: Number(v.lng), + ...(label ? { label } : {}), + tone: TONES.has(v.tone as string) ? (v.tone as Tone) : 'default', + ...(Number.isFinite(dwell) && dwell >= 0 ? { dwellSeconds: Math.min(Math.round(dwell), MAX_DWELL_S) } : {}), + }); + } + } + + return { pluginId, profile, coordinates, distance, duration, legs, viaPoints }; +} + +export function declaredProfiles(conn: Database.Database, pluginId: string): string[] { + try { + const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as + | { capabilities?: string } + | undefined; + const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; + if (!Array.isArray(c.routeProfiles)) return []; + return c.routeProfiles + .map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : '')) + .filter((id) => PROFILE_RE.test(id)); + } catch { + return []; + } +} diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index 6f19cc47f..fd8f07699 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -1,12 +1,12 @@ import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; -import type Database from 'better-sqlite3'; import { DatabaseService } from '../../database/database.service'; import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; -import { stripEmoji } from '../text-sanitize'; import { PluginRouteDto } from '../plugins.dto'; +import { normalize, declaredProfiles, readWaypoints, PROFILE_RE, type PluginRouteOut } from './plugin-route-normalize'; +export type { PluginRouteOut } from './plugin-route-normalize'; /** * POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to @@ -27,126 +27,6 @@ import { PluginRouteDto } from '../plugins.dto'; * the invoke timeout is 20 s — well above the 5 s of the render-blocking hooks; * the client shows the straight-line route until the result lands. */ -type Tone = 'default' | 'success' | 'warn' | 'danger'; - -interface RouteWaypointIn { - lat: number; - lng: number; - name?: string; - placeId?: number; -} - -interface RouteLegOut { - distance: number; - duration: number; - note?: string; -} - -interface RouteViaOut { - lat: number; - lng: number; - label?: string; - tone: Tone; - dwellSeconds?: number; -} - -export interface PluginRouteOut { - pluginId: string; - profile: string; - coordinates: Array<[number, number]>; - distance: number; - duration: number; - legs: RouteLegOut[]; - viaPoints: RouteViaOut[]; -} - -const TONES: ReadonlySet = new Set(['default', 'success', 'warn', 'danger']); -const PROFILE_RE = /^[a-z][a-z0-9-]{0,23}$/; -const MAX_WAYPOINTS = 30; -const MAX_COORDINATES = 10_000; // vertex budget for the returned geometry -const MAX_VIAS = 40; -const MAX_DWELL_S = 86_400; // a "stop" longer than a day is nonsense data -const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); - -function validCoord(lat: unknown, lng: unknown): boolean { - const a = Number(lat); - const b = Number(lng); - return Number.isFinite(a) && Number.isFinite(b) && a >= -90 && a <= 90 && b >= -180 && b <= 180; -} - -/** The caller's waypoint list — invalid input is a client bug, answered with null. */ -function readWaypoints(raw: unknown): RouteWaypointIn[] | null { - if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_WAYPOINTS) return null; - const out: RouteWaypointIn[] = []; - for (const w of raw as Array>) { - if (!w || typeof w !== 'object' || !validCoord(w.lat, w.lng)) return null; - const name = w.name != null ? cap(w.name, 120) : undefined; - const placeId = Number.isInteger(w.placeId) ? (w.placeId as number) : undefined; - out.push({ lat: Number(w.lat), lng: Number(w.lng), ...(name ? { name } : {}), ...(placeId !== undefined ? { placeId } : {}) }); - } - return out; -} - -function nonNeg(v: unknown): number | null { - const n = Number(v); - return Number.isFinite(n) && n >= 0 ? n : null; -} - -/** The plugin's answer — a malformed route is rejected whole (null), never patched. */ -function normalize(pluginId: string, profile: string, waypointCount: number, raw: unknown): PluginRouteOut | null { - if (!raw || typeof raw !== 'object') return null; - const r = raw as Record; - - if (!Array.isArray(r.coordinates) || r.coordinates.length < 2 || r.coordinates.length > MAX_COORDINATES) return null; - const coordinates: Array<[number, number]> = []; - for (const c of r.coordinates) { - if (!Array.isArray(c) || !validCoord(c[0], c[1])) return null; - coordinates.push([Number(c[0]), Number(c[1])]); - } - - const distance = nonNeg(r.distance); - const duration = nonNeg(r.duration); - if (distance == null || duration == null) return null; - - // Exactly one leg per waypoint pair — a mismatched list means the plugin routed - // something other than the request, and mis-keyed legs would attach wrong times - // to the sidebar connectors. - if (!Array.isArray(r.legs) || r.legs.length !== waypointCount - 1) return null; - const legs: RouteLegOut[] = []; - for (const l of r.legs as Array>) { - if (!l || typeof l !== 'object') return null; - const legDistance = nonNeg(l.distance); - const legDuration = nonNeg(l.duration); - if (legDistance == null || legDuration == null) return null; - const note = l.note != null ? cap(l.note, 120) : undefined; - legs.push({ distance: legDistance, duration: legDuration, ...(note ? { note } : {}) }); - } - - // Via points are decorative (charging stops on the drawn route) — a bad entry - // is dropped individually, it can't invalidate the route. Slice the raw array so - // an all-invalid list can't force unbounded iteration (the output is MAX_VIAS, - // but only valid entries advance that counter). - const viaPoints: RouteViaOut[] = []; - if (Array.isArray(r.viaPoints)) { - for (const v of (r.viaPoints as Array>).slice(0, MAX_VIAS * 4)) { - if (viaPoints.length >= MAX_VIAS) break; - if (!v || typeof v !== 'object' || !validCoord(v.lat, v.lng)) continue; - const dwell = Number(v.dwellSeconds); - const label = v.label != null ? cap(v.label, 80) : undefined; - viaPoints.push({ - lat: Number(v.lat), - lng: Number(v.lng), - ...(label ? { label } : {}), - // Raw-value enum check, same rationale as the marker tone guard. - tone: TONES.has(v.tone as string) ? (v.tone as Tone) : 'default', - ...(Number.isFinite(dwell) && dwell >= 0 ? { dwellSeconds: Math.min(Math.round(dwell), MAX_DWELL_S) } : {}), - }); - } - } - - return { pluginId, profile, coordinates, distance, duration, legs, viaPoints }; -} - @Controller('api/plugin-routes') @UseGuards(JwtAuthGuard) export class PluginRoutesController { @@ -186,16 +66,3 @@ export class PluginRoutesController { } } } - -function declaredProfiles(conn: Database.Database, pluginId: string): string[] { - try { - const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as { capabilities?: string } | undefined; - const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; - if (!Array.isArray(c.routeProfiles)) return []; - return c.routeProfiles - .map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : '')) - .filter((id) => PROFILE_RE.test(id)); - } catch { - return []; - } -} diff --git a/server/src/nest/roadtrip/roadtrip-plan.service.ts b/server/src/nest/roadtrip/roadtrip-plan.service.ts new file mode 100644 index 000000000..daa2ab678 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-plan.service.ts @@ -0,0 +1,242 @@ +import { DatabaseService } from '../database/database.service'; +import { SettingsService } from '../settings/settings.service'; +import { DayBoundariesService } from './day-boundaries.service'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { RoadtripRouterService } from './roadtrip-router.service'; +import { RoadtripService } from './roadtrip.service'; +import { Injectable, HttpException } from '@nestjs/common'; +import { type RoadtripPreferences } from '@trek/shared'; +import { + assembleRoadtrip, + foldRouteRun, + splitIntoRuns, + spillChains, + dayWindow, + effectiveRangeKm, + parseAvoid, + type RoadtripStop, + type PlanDay, + type RoutedLeg, + type SnappedWaypoint, + type RouteAvoidClass, +} from '@trek/shared/roadtrip'; + +interface StoredDay { + id: number; + day_number: number; + date: string | null; + title: string | null; + default_transport_mode: string | null; +} +interface VisitRow { + id: number; + day_id: number; + place_id: number; + name: string; + lat: number | null; + lng: number | null; + time: string | null; + duration_minutes: number | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + stop_type: string | null; + fill_percent: number | null; +} +const stopKey = (s: RoadtripStop) => + `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; + +@Injectable() +export class RoadtripPlanService { + constructor( + private readonly db: DatabaseService, + private readonly settings: SettingsService, + private readonly preferences: RoadtripPreferencesService, + private readonly router: RoadtripRouterService, + private readonly roadtrip: RoadtripService, + private readonly boundaries: DayBoundariesService, + ) {} + + context(tripId: number, userId: number) { + if (!this.db.canAccessTrip(tripId, userId)) throw new HttpException({ error: 'Trip not found' }, 404); + const days = this.db.all( + 'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number', + tripId, + ); + const visits = this.db.all( + `SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng, + COALESCE(a.assignment_time, p.place_time) AS time, p.duration_minutes, a.end_day, + a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent + FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id + WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`, + tripId, + ); + return { + days, + visits, + settings: this.preferences.read(userId), + profiles: this.router.profiles(), + vias: this.roadtrip.listForTrip(tripId), + tracks: this.roadtrip.tracksForTrip(tripId), + boundaries: this.boundaries.list(tripId), + }; + } + + async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) { + const context = this.context(tripId, userId); + const preferences = { ...context.settings, ...overrides }; + const window = dayWindow( + preferences.roadtrip_day_start, + preferences.roadtrip_day_end, + preferences.roadtrip_day_end_mode, + ); + if (preferences.roadtrip_day_start && preferences.roadtrip_day_end && !window) + throw new HttpException({ error: 'Day end must be later than day start.' }, 400); + const plan: PlanDay[] = context.days.map((day) => ({ + dayId: day.id, + dayNumber: day.day_number, + date: day.date, + title: day.title, + stops: context.visits + .filter((v) => v.day_id === day.id && v.lat !== null && v.lng !== null) + .map((v, index) => ({ + assignmentId: v.id, + ownerDayId: day.id, + ownerIndex: index, + placeId: v.place_id, + name: v.name, + lat: v.lat!, + lng: v.lng!, + time: v.time, + dwellMinutes: v.duration_minutes, + endDay: v.end_day === 1, + legMode: v.leg_transport_mode, + incomingLegMode: v.incoming_leg_transport_mode, + stopType: v.stop_type, + fillPercent: v.fill_percent, + })), + })); + const allLegs: Record = {}; + const snapByDay: Record> = {}; + const missedByDay: Record = {}; + const failures: { fromAssignmentId: number; toAssignmentId: number; reason: string }[] = []; + const avoid = parseAvoid(preferences.roadtrip_avoid); + const connectDays = preferences.roadtrip_connect_days || window !== null; + if (context.visits.length > 150) + throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400); + const asked = new Set(); + const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => { + const points: { lat: number; lng: number }[] = []; + const stopAt: number[] = []; + stops.forEach((stop, index) => { + stopAt.push(points.length); + points.push({ lat: stop.lat, lng: stop.lng }); + if (index < stops.length - 1) + points.push( + ...context.vias + .filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex) + .sort((a, b) => a.sequence - b.sequence) + .map((v) => ({ lat: v.lat, lng: v.lng })), + ); + }); + const pairs = stops.slice(0, -1).map((from, index) => ({ from, to: stops[index + 1] })); + pairs.forEach(({ from, to }) => asked.add(stopKey(from) + '>' + stopKey(to))); + try { + if (points.length > 100) throw new Error('Too many waypoints'); + const routed = await this.router.route(userId, tripId, dayId, points, profile, avoid); + const legs = routed.parts.map((part, index) => ({ + ...routed.leg.seg, + ...part, + from: [points[index].lat, points[index].lng] as [number, number], + to: [points[index + 1].lat, points[index + 1].lng] as [number, number], + })); + Object.assign( + allLegs, + foldRouteRun(stops, stopAt, { coordinates: routed.leg.line, legs, vias: routed.leg.vias }, profile), + ); + missedByDay[dayId] = [...new Set([...(missedByDay[dayId] ?? []), ...routed.avoidMissed])]; + stops.forEach((stop, index) => { + const snapped = routed.snapped?.[stopAt[index]]; + if (snapped) (snapByDay[stop.ownerDayId] ??= {})[stopKey(stop)] = snapped; + }); + } catch { + pairs.forEach(({ from, to }) => + failures.push({ + fromAssignmentId: from.assignmentId, + toAssignmentId: to.assignmentId, + reason: + points.length > 100 + ? 'More than 100 waypoints in this run.' + : 'Routing provider could not calculate this run. Check the profile, connection and provider limits.', + }), + ); + } + }; + for (const day of plan.filter((d) => d.stops.length > 1)) { + const fallback = context.days.find((d) => d.id === day.dayId)?.default_transport_mode || 'driving'; + for (const run of splitIntoRuns(day.stops, (from) => from.legMode ?? fallback)) + await fetchRun(run.stops, day.dayId, run.mode); + } + const chains = window + ? plan.filter((d) => d.stops.length) + : spillChains( + plan.filter((d) => d.stops.length > 1), + plan.filter((d) => d.stops.length < 2), + (a, b) => allLegs[stopKey(a) + '>' + stopKey(b)], + ); + let previous: RoadtripStop | undefined; + for (const chain of chains) { + const pairs = chain.stops.slice(0, -1).map((from, index) => ({ from, to: chain.stops[index + 1] })); + if (connectDays && previous && chain.stops.length) pairs.push({ from: previous, to: chain.stops[0] }); + for (const { from, to } of pairs) { + if (asked.has(stopKey(from) + '>' + stopKey(to))) continue; + await fetchRun( + [from, to], + chain.dayId, + from.legMode ?? context.days.find((d) => d.id === chain.dayId)?.default_transport_mode ?? 'driving', + ); + } + previous = chain.stops[chain.stops.length - 1] ?? previous; + } + const vehicleKind = preferences.roadtrip_vehicle || null; + const rangeKm = + effectiveRangeKm( + vehicleKind, + { + tankLitres: preferences.roadtrip_tank_litres, + litresPer100: preferences.roadtrip_litres_per_100, + batteryKwh: preferences.roadtrip_battery_kwh, + kwhPer100: preferences.roadtrip_kwh_per_100, + degradationPercent: preferences.roadtrip_battery_degradation, + }, + preferences.roadtrip_range_km, + ) || null; + const calculated = assembleRoadtrip({ + plan: plan.filter((d) => d.stops.length > 1), + quietDays: plan.filter((d) => d.stops.length < 2), + window, + allLegs, + snapByDay, + missedByDay, + loading: false, + limits: { + legMinutes: preferences.roadtrip_leg_minutes || null, + dayMinutes: preferences.roadtrip_day_minutes || null, + rangeKm, + fillPercent: preferences.roadtrip_fill_percent || null, + }, + vehicleKind, + connectDays, + boundaries: context.boundaries, + distanceUnit: this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric', + labels: { start: 'Continue journey', end: 'End of day' }, + }); + return { + context, + preferences, + calculated, + failures, + omittedVisits: context.visits.filter((v) => v.lat === null || v.lng === null).map((v) => v.id), + }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts new file mode 100644 index 000000000..7aad184e4 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -0,0 +1,157 @@ +import { ADDON_IDS } from '../../addons'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, errorResult, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { MapsService } from '../maps/maps.service'; +import { RoadtripPlanService } from './roadtrip-plan.service'; +import { + roadtripPlanRequestSchema, + roadtripCorridorRequestSchema, + type RoadtripPlanRequest, + type RoadtripCorridorRequest, +} from '@trek/shared'; +import { corridorTiles, projectOntoRoute, simplifyLine } from '@trek/shared/roadtrip'; + +import { z } from 'zod'; + +const when = addonGate(ADDON_IDS.ROADTRIP); + +@McpController() +export class RoadtripPlanningMcp { + constructor( + private readonly plans: RoadtripPlanService, + private readonly maps: MapsService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'get_roadtrip_context', + description: + 'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes your personal driving preferences. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings affect your account; visits and manual boundaries affect the shared trip.', + inputSchema: { tripId: z.number().int().positive() }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async context({ tripId }: { tripId: number }, ctx: McpContext) { + return ok(this.plans.context(tripId, ctx.userId)); + } + + @Tool({ + name: 'calculate_roadtrip', + description: + 'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority; no accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.', + inputSchema: roadtripPlanRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async calculate(input: RoadtripPlanRequest, ctx: McpContext) { + const plan = await this.plans.calculate(input.tripId, ctx.userId, input.settings); + const { calculated } = plan; + return ok({ + complete: !plan.failures.length && !plan.omittedVisits.length && !calculated.dayWindowIssue, + settings: plan.preferences, + failures: plan.failures, + omittedVisitIds: plan.omittedVisits, + dayWindowIssue: calculated.dayWindowIssue, + totalDistanceMetres: calculated.totalDistance, + totalDurationSeconds: calculated.totalDuration, + totalStops: calculated.totalStops, + days: calculated.days.map((day) => ({ + dayId: day.dayId, + dayNumber: day.dayNumber, + date: day.date, + title: day.title, + stops: day.stops, + schedule: day.schedule, + legs: day.legs, + distanceMetres: day.distance, + durationSeconds: day.duration, + driveWarnings: day.driveWarnings, + dayWarning: day.dayWarning, + avoidMissed: day.avoidMissed, + dryPoints: day.dryPoints, + ...(input.includeGeometry ? { geometry: day.geometry } : {}), + })), + quietDays: calculated.quietDays, + }); + } + + @Tool({ + name: 'search_roadtrip_corridor', + description: + 'Search for fuel, charging, rest areas, campsites, food, sights or accommodation along a calculated roadtrip day, without a browser. Uses the routed road, not a straight line. Returns distance along the route and distance from it. Search rectangles are paged in batches of six; continue with nextOffset until null for full coverage. Optional name, socket family and minimum power filter the matches; missing charging details mean unknown. Does not add stops. Add a chosen place and assign it to the appropriate stored day, preserving any via anchors. Routing failures refuse the corridor search rather than searching an invented line.', + inputSchema: roadtripCorridorRequestSchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, + when, + }) + async corridor(input: RoadtripCorridorRequest, ctx: McpContext) { + const plan = await this.plans.calculate(input.tripId, ctx.userId); + if (plan.failures.length || plan.omittedVisits.length || plan.calculated.dayWindowIssue) + return errorResult( + 'Resolve missing coordinates, routing or day-window conflicts before searching this corridor.', + ); + if (input.fromKm != null && input.toKm != null && input.fromKm > input.toKm) + return errorResult('fromKm must not exceed toKm.'); + const day = plan.calculated.days.find((d) => d.dayNumber === input.dayNumber); + if (!day?.geometry.length) return errorResult('This day has no routed road.'); + const line = simplifyLine( + day.geometry.map(([lat, lng]) => ({ lat, lng })), + Math.max(1, input.widthKm / 3), + ); + const tiles = corridorTiles(line, input.widthKm); + const hits = new Map< + string, + { poi: Awaited>['pois'][number]; alongKm: number; distanceKm: number } + >(); + const page = tiles.slice(input.offset, input.offset + 6); + const failedAreas: number[] = []; + let truncatedAreas = 0; + const sources = new Set(); + for (const bbox of page) { + try { + const found = await this.maps.pois(input.category, bbox); + sources.add(found.source); + if (found.truncated || found.clamped) truncatedAreas++; + for (const poi of found.pois) { + const projection = projectOntoRoute(poi, line); + if (!projection || projection.offRouteKm > input.widthKm) continue; + if ( + (input.fromKm != null && projection.alongKm < input.fromKm) || + (input.toKm != null && projection.alongKm > input.toKm) + ) + continue; + if ( + input.name && + !`${poi.name} ${poi.brand ?? ''}`.toLocaleLowerCase().includes(input.name.toLocaleLowerCase()) + ) + continue; + if (poi.category === 'charging') { + if (input.socket && !poi.charging?.sockets.some((socket) => socket.type === input.socket)) continue; + if ( + input.minKw && + poi.charging?.sockets.some((socket) => socket.kw != null) && + !poi.charging.sockets.some((socket) => (socket.kw ?? 0) >= input.minKw!) + ) + continue; + } + hits.set(poi.osm_id, { poi, alongKm: projection.alongKm, distanceKm: projection.offRouteKm }); + } + } catch { + failedAreas.push(input.offset + page.indexOf(bbox)); + } + } + return ok({ + dayNumber: day.dayNumber, + sources: [...sources], + complete: !failedAreas.length && !truncatedAreas && input.offset + page.length >= tiles.length, + failedAreas, + truncatedAreas, + totalAreas: tiles.length, + hits: [...hits.values()].sort((a, b) => a.alongKm - b.alongKm), + nextOffset: input.offset + page.length < tiles.length ? input.offset + page.length : null, + }); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts new file mode 100644 index 000000000..9949078c4 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -0,0 +1,53 @@ +import { ADDON_IDS } from '../../addons'; +import { + McpController, + Tool, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + demoDenied, + ok, + type McpContext, +} from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { AuthService } from '../auth/auth.service'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'; + +const when = addonGate(ADDON_IDS.ROADTRIP); + +@McpController() +export class RoadtripPreferencesMcp { + constructor( + private readonly preferences: RoadtripPreferencesService, + private readonly auth: AuthService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'get_roadtrip_settings', + description: + 'Read your personal driving preferences, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. These preferences apply to all your trips, not to other travellers. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'settings', mode: 'read' }, + when, + }) + async read(_input: Record, ctx: McpContext) { + return ok({ settings: this.preferences.read(ctx.userId), scope: 'current_user' }); + } + + @Tool({ + name: 'update_roadtrip_settings', + description: + 'Change personal driving preferences for all your trips. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.', + inputSchema: { settings: roadtripPreferencesUpdateSchema }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'settings', mode: 'write' }, + when, + }) + async update({ settings }: { settings: RoadtripPreferences }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + return ok({ settings: this.preferences.update(ctx.userId, settings), scope: 'current_user' }); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-preferences.service.ts b/server/src/nest/roadtrip/roadtrip-preferences.service.ts new file mode 100644 index 000000000..b68ffa7dd --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-preferences.service.ts @@ -0,0 +1,39 @@ +import { RealtimeService } from '../realtime/realtime.service'; +import { SettingsService } from '../settings/settings.service'; +import { HttpException, Injectable } from '@nestjs/common'; +import { + ROADTRIP_PREFERENCE_KEYS, + roadtripPreferencesSchema, + roadtripPreferencesUpdateSchema, + type RoadtripPreferences, +} from '@trek/shared'; + +@Injectable() +export class RoadtripPreferencesService { + constructor( + private readonly settings: SettingsService, + private readonly realtime: RealtimeService, + ) {} + + read(userId: number): RoadtripPreferences { + const settings = this.settings.getUserSettings(userId); + const preferences: Record = {}; + for (const key of ROADTRIP_PREFERENCE_KEYS) { + const parsed = roadtripPreferencesSchema.shape[key].safeParse(settings[key]); + if (parsed.success && parsed.data !== undefined) preferences[key] = parsed.data; + } + return roadtripPreferencesSchema.parse(preferences); + } + + update(userId: number, patch: RoadtripPreferences): RoadtripPreferences { + const validated = roadtripPreferencesUpdateSchema.parse(patch); + const next = { ...this.read(userId), ...validated }; + if (next.roadtrip_day_start && next.roadtrip_day_end && next.roadtrip_day_end <= next.roadtrip_day_start) { + throw new HttpException({ error: 'Day end must be later than day start.' }, 400); + } + this.settings.bulkUpsertSettings(userId, validated); + const saved = this.read(userId); + this.realtime.broadcastToUser(userId, { type: 'roadtripPreferences:changed', preferences: saved }); + return saved; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-router.service.ts b/server/src/nest/roadtrip/roadtrip-router.service.ts new file mode 100644 index 000000000..99945476b --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-router.service.ts @@ -0,0 +1,306 @@ +import { readCappedJson, discardBody } from '../../utils/cappedFetch'; +import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; +import { DatabaseService } from '../database/database.service'; +import { normalize, declaredProfiles } from '../plugins/contributions/plugin-route-normalize'; +import { pluginsEnabled } from '../plugins/kill-switch'; +import { PluginHooks } from '../plugins/plugin-hooks.service'; +import { SettingsService } from '../settings/settings.service'; +import { Injectable } from '@nestjs/common'; +import { + formatDurationShort, + haversineKm, + type RoutedLeg, + type RouteAvoidClass, + type SnappedWaypoint, +} from '@trek/shared/roadtrip'; + +import { z } from 'zod'; + +const coordinate = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]); +const osrmSchema = z.object({ + code: z.literal('Ok'), + routes: z + .array( + z.object({ + distance: z.number().nonnegative(), + duration: z.number().nonnegative(), + legs: z + .array(z.object({ distance: z.number().nonnegative(), duration: z.number().nonnegative() })) + .min(1) + .max(99), + geometry: z.object({ coordinates: z.array(coordinate).min(2).max(200000) }), + }), + ) + .min(1), + waypoints: z.array(z.object({ location: coordinate, distance: z.number().nonnegative() })).optional(), +}); +const valhallaSchema = z.object({ + trip: z.object({ + legs: z + .array( + z.object({ + shape: z.string().max(2000000), + summary: z.object({ + length: z.number().nonnegative(), + time: z.number().nonnegative(), + has_toll: z.boolean().optional(), + has_highway: z.boolean().optional(), + has_ferry: z.boolean().optional(), + }), + }), + ) + .min(1) + .max(99), + }), +}); +type Point = { lat: number; lng: number }; +export interface RoadtripRoute { + leg: RoutedLeg; + parts: { distance: number; duration: number }[]; + snapped?: SnappedWaypoint[]; + avoidMissed: RouteAvoidClass[]; +} + +function decode(shape: string): [number, number][] { + const line: [number, number][] = []; + let index = 0, + lat = 0, + lng = 0; + while (index < shape.length) { + const pair: number[] = []; + for (let axis = 0; axis < 2; axis++) { + let value = 0, + shift = 0, + byte: number; + do { + if (index >= shape.length || shift > 30) throw new Error('Invalid route geometry'); + byte = shape.charCodeAt(index++) - 63; + if (byte < 0 || byte > 63) throw new Error('Invalid route geometry'); + value |= (byte & 31) << shift; + shift += 5; + } while (byte >= 32); + pair.push(value & 1 ? ~(value >> 1) : value >> 1); + } + lat += pair[0]; + lng += pair[1]; + coordinate.parse([lng / 1e6, lat / 1e6]); + line.push([lat / 1e6, lng / 1e6]); + } + if (line.length < 2) throw new Error('Invalid route geometry'); + return line; +} + +@Injectable() +export class RoadtripRouterService { + private nextRequest = 0; + private queue: Promise = Promise.resolve(); + private cache = new Map(); + + constructor( + private readonly settings: SettingsService, + private readonly hooks: PluginHooks, + private readonly db: DatabaseService, + ) {} + + profiles(): string[] { + return [ + 'driving', + 'walking', + 'cycling', + ...(pluginsEnabled() + ? this.hooks + .providersOf('routeProvider') + .flatMap((id) => declaredProfiles(this.db.connection, id).map((profile) => `plugin:${id}/${profile}`)) + : []), + ]; + } + + private async request(url: string, body?: unknown): Promise { + const before = this.queue; + let release!: () => void; + this.queue = new Promise((resolve) => { + release = resolve; + }); + await before; + try { + const delay = this.nextRequest - Date.now(); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + this.nextRequest = Date.now() + 1100; + const response = await safeFetchAdminConfigured(url, { + signal: AbortSignal.timeout(20000), + headers: { 'User-Agent': 'TREK Roadtrip', 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, + ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}), + }); + if (!response.ok) { + discardBody(response); + throw new Error(`Routing provider returned ${response.status}`); + } + const payload = await readCappedJson(response, 8 * 1024 * 1024); + if (payload === undefined) throw new Error('Routing response is invalid or too large'); + return payload; + } finally { + release(); + } + } + + async route( + userId: number, + tripId: number, + dayId: number, + points: Point[], + profile: string, + avoid: RouteAvoidClass[], + ): Promise { + const settings = this.settings.getUserSettings(userId); + const key = JSON.stringify([ + userId, + tripId, + dayId, + points, + profile, + avoid, + settings.routing_base_url, + settings.valhalla_base_url, + ]); + const cached = this.cache.get(key); + if (!profile.startsWith('plugin:') && cached && Date.now() - cached.at < 300000) return cached.route; + let line: [number, number][], distance: number, duration: number; + let parts: { distance: number; duration: number }[]; + let vias: RoutedLeg['vias'] = []; + let avoidMissed = profile === 'driving' ? [...avoid] : []; + let snapped: SnappedWaypoint[] | undefined; + const plugin = /^plugin:([^/]+)\/(.+)$/.exec(profile); + if (plugin) { + const [, id, name] = plugin; + if ( + points.length > 30 || + !pluginsEnabled() || + !this.hooks.providersOf('routeProvider').includes(id) || + !declaredProfiles(this.db.connection, id).includes(name) + ) + throw new Error('Routing plugin is unavailable or has too many waypoints'); + const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId); + const route = normalize(id, name, points.length, raw); + if (!route) throw new Error('Routing plugin could not calculate this leg'); + line = route.coordinates; + distance = route.distance; + duration = route.duration; + vias = route.viaPoints; + parts = route.legs; + } else { + const mode = profile === 'walking' || profile === 'cycling' ? profile : 'driving'; + const base = + typeof settings.routing_base_url === 'string' ? settings.routing_base_url.trim().replace(/\/+$/, '') : ''; + const configuredValhalla = + typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : ''; + const valhalla = configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de'); + let weighted = false; + if (mode === 'driving' && avoid.length && valhalla) { + try { + const options = { + use_tolls: avoid.includes('toll') ? 0 : 1, + use_highways: avoid.includes('motorway') ? 0 : 1, + use_ferry: avoid.includes('ferry') ? 0 : 1, + }; + const legs: z.infer['trip']['legs'] = []; + for (let start = 0; start < points.length - 1; start += 9) { + const payload = await this.request(`${valhalla}/route`, { + locations: points.slice(start, start + 10).map((p) => ({ lat: p.lat, lon: p.lng, radius: 50 })), + costing: 'auto', + costing_options: { auto: options }, + directions_options: { units: 'kilometers' }, + }); + legs.push(...valhallaSchema.parse(payload).trip.legs); + } + if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); + const shapes = legs.map((leg) => decode(leg.shape)); + line = shapes.flat(); + const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!]; + snapped = locations.map(([lat, lng], index) => ({ + asked: [points[index].lat, points[index].lng], + at: [lat, lng], + meters: haversineKm(points[index], { lat, lng }) * 1000, + })); + distance = legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0); + duration = legs.reduce((sum, leg) => sum + leg.summary.time, 0); + parts = legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time })); + avoidMissed = avoid.filter((cls) => + legs.some((leg) => + cls === 'toll' + ? leg.summary.has_toll + : cls === 'motorway' + ? leg.summary.has_highway + : leg.summary.has_ferry, + ), + ); + weighted = true; + } catch { + avoidMissed = [...avoid]; + snapped = undefined; + } + } + if (!weighted) { + const path = mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'driving'; + const origin = + base || + `https://routing.openstreetmap.de/routed-${mode === 'walking' ? 'foot' : mode === 'cycling' ? 'bike' : 'car'}`; + const payload = await this.request( + `${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false`, + ); + const parsed = osrmSchema.parse(payload); + const route = parsed.routes[0]; + line = route.geometry.coordinates.map(([lng, lat]) => [lat, lng]); + distance = route.distance; + duration = route.duration; + parts = route.legs; + if (parsed.waypoints?.length === points.length) + snapped = parsed.waypoints.map((waypoint, index) => ({ + asked: [points[index].lat, points[index].lng], + at: [waypoint.location[1], waypoint.location[0]], + meters: waypoint.distance, + })); + } + } + const from = line[0], + to = line[line.length - 1]; + const durationText = formatDurationShort(duration); + const distanceText = `${Math.round(distance / 100) / 10} km`; + if (parts.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); + const route = { + parts, + snapped, + leg: { + line, + vias, + seg: { + from, + to, + mid: line[Math.floor(line.length / 2)], + distance, + duration, + durationText, + distanceText, + drivingText: durationText, + walkingText: durationText, + mode: profile, + }, + snapped: { + from: { + lat: from[0], + lng: from[1], + offRoadMeters: haversineKm(points[0], { lat: from[0], lng: from[1] }) * 1000, + }, + to: { + lat: to[0], + lng: to[1], + offRoadMeters: haversineKm(points[points.length - 1], { lat: to[0], lng: to[1] }) * 1000, + }, + }, + }, + avoidMissed, + }; + this.cache.set(key, { at: Date.now(), route }); + if (this.cache.size > 100) this.cache.delete(this.cache.keys().next().value!); + return route; + } +} diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index c7b08e084..c584bbab6 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -8,6 +8,7 @@ import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { roadtripViaUpdateRequestSchema, type RoadtripViaUpdateRequest } from '@trek/shared'; /** * The whole surface rides the road trip addon, the same way the controller does @@ -184,6 +185,23 @@ export class RoadtripMcp { return ok({ success: true }); } + @Tool({ + name: 'update_route_via', + description: 'Move an existing via point and optionally attach it to another outgoing leg of the same day. This is the same change as dragging a route handle. A via bends the route and does not add a stop or stay.', + inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), viaId: z.number().int().positive(), ...roadtripViaUpdateRequestSchema.shape }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn, + }) + async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId)) return permissionDenied(); + if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess(); + const via = this.roadtrip.move(input.viaId, input.dayId, input.lat, input.lng, input.after_order_index); + if (!via) return noAccess(); + this.announce(input.tripId, input.dayId); + return ok({ via }); + } + /** * Says what this day's drive is routed through now. * diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 9e224c026..a339248ec 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -10,14 +10,22 @@ import { AddonsModule } from '../addons/addons.module'; import { DayBoundariesController } from './day-boundaries.controller'; import { DayBoundariesService } from './day-boundaries.service'; import { DayBoundariesMcp } from './day-boundaries.mcp'; +import { SettingsModule } from '../settings/settings.module'; +import { RoadtripPreferencesService } from './roadtrip-preferences.service'; +import { RoadtripPreferencesMcp } from './roadtrip-preferences.mcp'; +import { RoadtripRouterService } from './roadtrip-router.service'; +import { RoadtripPlanService } from './roadtrip-plan.service'; +import { RoadtripPlanningMcp } from './roadtrip-planning.mcp'; +import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module'; +import { MapsModule } from '../maps/maps.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, RealtimeModule], + imports: [McpSharedModule, PermissionsModule, AuthModule, AddonsModule, RealtimeModule, SettingsModule, PluginsRuntimeModule, MapsModule], controllers: [RoadtripController, DayBoundariesController], - providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp], + providers: [RoadtripService, RoadtripMcp, DayBoundariesService, DayBoundariesMcp, RoadtripPreferencesService, RoadtripPreferencesMcp, RoadtripRouterService, RoadtripPlanService, RoadtripPlanningMcp], exports: [RoadtripService], }) export class RoadtripModule {} diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts new file mode 100644 index 000000000..f53bda1f3 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -0,0 +1,302 @@ +import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; +import type { McpContext } from '../../../src/nest-mcp'; +import { createTestRegistry } from '../../../src/nest-mcp'; +import { PlacesMcp } from '../../../src/nest/places/places.mcp'; +import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service'; +import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp'; +import { RoadtripPreferencesMcp } from '../../../src/nest/roadtrip/roadtrip-preferences.mcp'; +import { RoadtripPreferencesService } from '../../../src/nest/roadtrip/roadtrip-preferences.service'; +import { RoadtripMcp } from '../../../src/nest/roadtrip/roadtrip.mcp'; +import { roadtripPreferencesUpdateSchema } from '@trek/shared'; + +import { describe, it, expect, vi } from 'vitest'; + +const ctx = { userId: 5 } as McpContext; +function setup() { + let settings: Record = { + roadtrip_day_start: '08:00', + roadtrip_day_end: '10:00', + roadtrip_vehicle: 'electric', + roadtrip_range_km: 100, + roadtrip_fill_percent: 80, + routing_base_url: 'https://private.example', + llm_api_key: 'secret', + }; + const store = { + getUserSettings: vi.fn(() => settings), + bulkUpsertSettings: vi.fn((_id: number, patch: Record) => { + settings = { ...settings, ...patch }; + }), + }; + const realtime = { broadcastToUser: vi.fn() }; + const preferences = new RoadtripPreferencesService(store as never, realtime as never); + const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }]; + const visits = [1, 2, 3].map((id) => ({ + id, + day_id: 1, + place_id: id + 10, + name: `Stop ${id}`, + lat: 48, + lng: id, + time: id === 1 ? '07:00' : null, + duration_minutes: 30, + end_day: 0, + leg_transport_mode: null, + incoming_leg_transport_mode: null, + stop_type: null, + fill_percent: null, + })); + const db = { + canAccessTrip: vi.fn(() => true), + all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)), + }; + const router = { + profiles: () => ['driving'], + route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({ + parts: points.slice(1).map(() => ({ distance: 120000, duration: 7200 })), + avoidMissed: [], + leg: { + line: points.map((p) => [p.lat, p.lng]), + vias: [], + seg: { + from: [48, 1], + to: [48, 3], + mid: [48, 2], + distance: 240000, + duration: 14400, + mode: 'driving', + distanceText: '', + drivingText: '', + walkingText: '', + }, + }, + })), + }; + const roadtrip = { listForTrip: vi.fn(() => []), tracksForTrip: vi.fn(() => []) }; + const boundaries = { list: vi.fn(() => []) }; + const plans = new RoadtripPlanService( + db as never, + store as never, + preferences, + router as never, + roadtrip as never, + boundaries as never, + ); + return { preferences, store, realtime, db, plans, router, visits, days, boundaries }; +} + +describe('roadtrip preferences', () => { + it('reads only public driving preferences and never endpoint URLs or credentials', () => { + const s = setup(); + expect(s.preferences.read(5)).toMatchObject({ roadtrip_range_km: 100 }); + expect(JSON.stringify(s.preferences.read(5))).not.toMatch(/secret|private.example|routing_base_url/); + }); + it('validates the complete window before an atomic write and broadcasts only to its owner', () => { + const s = setup(); + expect(() => s.preferences.update(5, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow(); + expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled(); + s.preferences.update(5, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); + expect(s.realtime.broadcastToUser).toHaveBeenCalledWith(5, { + type: 'roadtripPreferences:changed', + preferences: s.preferences.read(5), + }); + expect(s.preferences.read(5).roadtrip_range_km).toBe(100); + }); + it.each([ + { llm_api_key: 'x' }, + { routing_base_url: 'http://localhost' }, + { roadtrip_day_start: '25:00' }, + { roadtrip_fill_percent: 101 }, + { roadtrip_avoid: 'anything' }, + {}, + ])('refuses invalid or unrelated preference %j', (patch) => { + expect(roadtripPreferencesUpdateSchema.safeParse(patch).success).toBe(false); + }); + it('allows clearing daily times and limits and refuses demo writes', async () => { + const s = setup(); + s.preferences.update(5, { roadtrip_day_start: '', roadtrip_range_km: 0 }); + expect(s.preferences.read(5).roadtrip_day_start).toBe(''); + const mcp = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => true } as never, {} as never); + s.store.bulkUpsertSettings.mockClear(); + await mcp.update({ settings: { roadtrip_range_km: 300 } }, ctx); + expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled(); + }); +}); + +describe('browser-independent roadtrip calculation', () => { + it('uses the shared scheduler, preserves the manually pinned start and routes a whole day in one request', async () => { + const s = setup(); + const before = structuredClone(s.visits); + const plan = await s.plans.calculate(10, 5); + expect(s.router.route).toHaveBeenCalledTimes(1); + expect(plan.calculated.days[0].schedule.entries[0].arrival).toBe('07:00'); + expect(plan.calculated.days.length).toBeGreaterThan(1); + expect(plan.calculated.days[0].stops.some((stop) => stop.automaticNight?.phase === 'end')).toBe(true); + expect(plan.calculated.days[0].driveWarnings.some((w) => w.code === 'range')).toBe(true); + expect(s.visits).toEqual(before); + }); + it('previews settings without saving them', async () => { + const s = setup(); + await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' }); + expect(s.preferences.read(5).roadtrip_day_end).toBe('10:00'); + expect(s.store.bulkUpsertSettings).not.toHaveBeenCalled(); + }); + it('checks trip access before reading or routing', async () => { + const s = setup(); + s.db.canAccessTrip.mockReturnValue(false); + await expect(s.plans.calculate(20, 5)).rejects.toThrow(); + expect(s.db.all).not.toHaveBeenCalled(); + expect(s.router.route).not.toHaveBeenCalled(); + }); + it('marks provider failures incomplete and never leaks an endpoint from its exception', async () => { + const s = setup(); + s.router.route.mockRejectedValue(new Error('secret https://private.example')); + const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never); + const answer = await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx); + expect(JSON.stringify(answer)).toContain('incomplete'); + expect(JSON.stringify(answer)).not.toContain('private.example'); + expect(JSON.stringify(answer)).toContain('false'); + }); + it('reports coordinate-less visits instead of inventing a location', async () => { + const s = setup(); + Object.assign(s.visits[1], { lat: null }); + const plan = await s.plans.calculate(10, 5); + expect(plan.omittedVisits).toEqual([2]); + }); + it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => { + const s = setup(); + s.visits[0].end_day = 1; + const plan = await s.plans.calculate(10, 5); + expect(plan.calculated.days[0].stops.find((stop) => stop.assignmentId === 1)?.endDay).toBe(true); + expect(plan.calculated.days[0].schedule.entries.some((entry) => entry.departure === '07:30')).toBe(true); + }); +}); + +describe('Roadtrip MCP registration and search', () => { + it('imports GPX through the existing service and broadcasts the imported places', async () => { + const places = { importGpx: vi.fn(() => ({ places: [{ id: 12 }], count: 1, skipped: 0 })) }; + const auth = { isDemoUser: vi.fn(() => false) }; + const guards = { hasTripPermission: () => true, safeBroadcast: vi.fn() }; + const mcp = new PlacesMcp( + places as never, + {} as never, + { canAccessTrip: () => true } as never, + auth as never, + {} as never, + {} as never, + guards as never, + ); + const input = { + tripId: 1, + gpx: '', + name: 'Track', + importTracks: true, + importRoutes: false, + importWaypoints: false, + }; + await mcp.importGpx(input, ctx); + expect(places.importGpx).toHaveBeenCalledWith('1', Buffer.from(''), { + defaultName: 'Track', + importTracks: true, + importRoutes: false, + importWaypoints: false, + }); + expect(guards.safeBroadcast).toHaveBeenCalledWith(1, 'place:created', { place: { id: 12 } }); + places.importGpx.mockClear(); + auth.isDemoUser.mockReturnValue(true); + await mcp.importGpx(input, ctx); + expect(places.importGpx).not.toHaveBeenCalled(); + }); + it('hides addon tools when disabled and separates settings reads from writes', () => { + const s = setup(); + const addons = { isAddonEnabled: vi.fn(() => true) }; + const registry = createTestRegistry( + [ + new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never), + new RoadtripPlanningMcp(s.plans, {} as never, addons as never), + ], + { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, + ); + const names: string[] = []; + const registrar = { + registerTool: (name: string) => { + names.push(name); + }, + }; + registry.attach(registrar as never, { ...ctx, scopes: ['settings:read'] }); + expect(names).toEqual(['get_roadtrip_settings']); + names.length = 0; + addons.isAddonEnabled.mockReturnValue(false); + registry.attach(registrar as never, { ...ctx, scopes: null }); + expect(names).toEqual([]); + }); + it('returns corridor sources, truncation and matching brands without adding stops', async () => { + const plans = { + calculate: vi.fn(async () => ({ + failures: [], + omittedVisits: [], + calculated: { + days: [ + { + dayNumber: 1, + geometry: [ + [48, 10], + [48, 10.1], + ], + }, + ], + dayWindowIssue: null, + }, + })), + }; + const maps = { + pois: vi.fn(async () => ({ + pois: [{ osm_id: 'n1', name: 'Fuel', brand: 'Example', lat: 48, lng: 10.05, category: 'fuel' }], + source: 'trek-places', + truncated: true, + clamped: false, + })), + }; + const mcp = new RoadtripPlanningMcp(plans as never, maps as never, {} as never); + const answer = await mcp.corridor( + { tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0, name: 'example' }, + ctx, + ); + const body = JSON.parse(answer.content[0].text as string); + expect(body.hits).toHaveLength(1); + expect(body.sources).toEqual(['trek-places']); + expect(body.truncatedAreas).toBeGreaterThan(0); + expect(body.complete).toBe(false); + maps.pois.mockRejectedValue(new Error('private endpoint')); + const failed = await mcp.corridor({ tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 }, ctx); + expect(JSON.stringify(failed)).toContain('failedAreas'); + expect(JSON.stringify(failed)).not.toContain('private endpoint'); + }); + it('checks visit editing permission and ownership before moving a via', async () => { + const service = { + dayExists: vi.fn(() => true), + move: vi.fn(() => ({ id: 4 })), + listForDay: () => [], + broadcast: vi.fn(), + }; + const guards = { hasTripPermission: vi.fn(() => false) }; + const mcp = new RoadtripMcp( + service as never, + { canAccessTrip: () => true } as never, + guards as never, + { isDemoUser: () => false } as never, + {} as never, + ); + const input = { tripId: 1, dayId: 2, viaId: 4, lat: 48, lng: 10 }; + await mcp.updateVia(input, ctx); + expect(service.move).not.toHaveBeenCalled(); + guards.hasTripPermission.mockReturnValue(true); + service.dayExists.mockReturnValue(false); + await mcp.updateVia(input, ctx); + expect(service.move).not.toHaveBeenCalled(); + service.dayExists.mockReturnValue(true); + await mcp.updateVia(input, ctx); + expect(service.move).toHaveBeenCalledWith(4, 2, 48, 10, undefined); + expect(service.broadcast).toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/roadtrip-router.test.ts b/server/tests/unit/nest/roadtrip-router.test.ts new file mode 100644 index 000000000..5b317f7e3 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-router.test.ts @@ -0,0 +1,164 @@ +import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service'; +import { safeFetchAdminConfigured } from '../../../src/utils/ssrfGuard'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchAdminConfigured: vi.fn() })); +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => true })); +const points = [ + { lat: 48, lng: 10 }, + { lat: 49, lng: 11 }, + { lat: 50, lng: 12 }, +]; +const osrm = () => + new Response( + JSON.stringify({ + code: 'Ok', + routes: [ + { + distance: 3000, + duration: 300, + geometry: { + coordinates: [ + [10, 48], + [11, 49], + [12, 50], + ], + }, + legs: [ + { distance: 1000, duration: 100 }, + { distance: 2000, duration: 200 }, + ], + }, + ], + waypoints: points.map((p) => ({ location: [p.lng, p.lat], distance: 25 })), + }), + ); +function setup(settings = {}) { + const hooks = { providersOf: vi.fn(() => ['scenic']), route: vi.fn() }; + const db = { + connection: { + prepare: () => ({ get: () => ({ capabilities: JSON.stringify({ routeProfiles: [{ id: 'car' }] }) }) }), + }, + }; + return { + hooks, + router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, db as never), + }; +} +beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(safeFetchAdminConfigured) + .mockReset() + .mockImplementation(async () => osrm()); +}); +afterEach(() => vi.useRealTimers()); + +describe('server Roadtrip router', () => { + it('reads Valhalla leg times, geometry and unmet road preferences', async () => { + const encode = (value: number) => { + let remaining = value < 0 ? ~(value << 1) : value << 1; + let encoded = ''; + while (remaining >= 32) { encoded += String.fromCharCode((32 | (remaining & 31)) + 63); remaining >>>= 5; } + return encoded + String.fromCharCode(remaining + 63); + }; + const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000); + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify({trip:{legs:[{shape,summary:{length:25,time:1200,has_toll:true,has_ferry:false}}]}}))); + const route = await setup().router.route(1,1,1,points.slice(0,2),'driving',['toll','ferry']); + expect(route.leg.line).toEqual([[48,10],[49,11]]); + expect(route.parts).toEqual([{distance:25000,duration:1200}]); + expect(route.avoidMissed).toEqual(['toll']); + expect(route.snapped).toHaveLength(2); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + }); + it('uses bounded SSRF-checked requests, keeps every snap and caches per user', async () => { + const { router } = setup(); + const route = await router.route(1, 1, 1, points, 'driving', []); + expect(route.parts.map((p) => p.duration)).toEqual([100, 200]); + expect(route.snapped).toHaveLength(3); + expect(route.snapped?.[1].meters).toBe(25); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringContaining('routing.openstreetmap.de/routed-car'), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + await router.route(1, 1, 1, points, 'driving', []); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + const other = router.route(2, 1, 1, points, 'driving', []); + await vi.runAllTimersAsync(); + await other; + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2); + }); + it('keeps a private OSRM instance private and reports unsupported avoidance', async () => { + const { router } = setup({ routing_base_url: 'http://router.internal/' }); + const route = await router.route(1, 1, 1, points, 'driving', ['toll']); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(1); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringMatching(/^http:\/\/router.internal\/route/), + expect.anything(), + ); + expect(route.avoidMissed).toEqual(['toll']); + }); + it('falls back from failed Valhalla without claiming avoidance succeeded', async () => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('', { status: 503 })); + const pending = setup().router.route(1, 1, 1, points, 'driving', ['ferry']); + await vi.runAllTimersAsync(); + expect((await pending).avoidMissed).toEqual(['ferry']); + expect(safeFetchAdminConfigured).toHaveBeenCalledTimes(2); + }); + it('does not apply car avoidance to walking routes', async () => { + const route = await setup().router.route(1, 1, 1, points, 'walking', ['toll']); + expect(route.avoidMissed).toEqual([]); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith( + expect.stringContaining('routed-foot/route/v1/foot/'), + expect.anything(), + ); + }); + it.each([ + { code: 'NoRoute' }, + { + code: 'Ok', + routes: [ + { + distance: 1, + duration: 1, + legs: [], + geometry: { + coordinates: [ + [999, 0], + [0, 0], + ], + }, + }, + ], + }, + ])('rejects malformed provider answers', async (body) => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response(JSON.stringify(body))); + await expect(setup().router.route(1, 1, 1, points, 'driving', [])).rejects.toThrow(); + }); + it('requires a granted plugin and a declared profile, without public fallback', async () => { + const { router, hooks } = setup(); + await expect(router.route(1, 1, 1, points, 'plugin:scenic/missing', [])).rejects.toThrow(); + expect(hooks.route).not.toHaveBeenCalled(); + expect(safeFetchAdminConfigured).not.toHaveBeenCalled(); + }); + it('normalizes plugin output under the requesting user and trip', async () => { + const { router, hooks } = setup(); + hooks.route.mockResolvedValue({ + coordinates: [ + [48, 10], + [49, 11], + [50, 12], + ], + distance: 3000, + duration: 300, + legs: [ + { distance: 1000, duration: 100 }, + { distance: 2000, duration: 200 }, + ], + viaPoints: [], + }); + expect((await router.route(7, 8, 9, points, 'plugin:scenic/car', [])).leg.seg.distance).toBe(3000); + expect(hooks.route).toHaveBeenCalledWith('scenic', { tripId: 8, dayId: 9, profile: 'car', waypoints: points }, 7); + expect(router.profiles()).toContain('plugin:scenic/car'); + }); +}); diff --git a/shared/package.json b/shared/package.json index 1145257bd..0abaa44ed 100644 --- a/shared/package.json +++ b/shared/package.json @@ -37,6 +37,16 @@ "types": "./dist/i18n/*/index.d.cts", "default": "./dist/i18n/*/index.cjs" } + }, + "./roadtrip": { + "import": { + "types": "./dist/roadtrip/planning.d.mts", + "default": "./dist/roadtrip/planning.mjs" + }, + "require": { + "types": "./dist/roadtrip/planning.d.cts", + "default": "./dist/roadtrip/planning.cjs" + } } }, "typesVersions": { diff --git a/shared/src/index.ts b/shared/src/index.ts index 63366a524..4bb5fae25 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -76,3 +76,5 @@ export * from './i18n/languages'; export * from './plugin-permissions'; // Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes. export * from './plugins/plugins.schema'; +export * from './roadtrip/preferences.schema'; +export * from './roadtrip/planning.schema'; diff --git a/shared/src/realtime/events.schema.spec.ts b/shared/src/realtime/events.schema.spec.ts index 39505def3..34e97cb74 100644 --- a/shared/src/realtime/events.schema.spec.ts +++ b/shared/src/realtime/events.schema.spec.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from 'vitest'; * fixture map cannot silently shrink relative to the registry. */ const FIXTURES: Record> = { + 'roadtripPreferences:changed': { preferences: { roadtrip_day_start: '08:00' } }, 'place:created': { place: { id: 3, name: 'Louvre' } }, 'place:updated': { place: { id: 3, name: 'Louvre' } }, 'place:deleted': { placeId: 3 }, @@ -30,7 +31,9 @@ const FIXTURES: Record> = { 'day:reordered': { orderedIds: [2, 1] }, 'roadtripVia:changed': { dayId: 2, vias: [{ id: 9, lat: 53.5, lng: 9.9 }] }, 'roadtripTrack:changed': { dayId: 2, track: null }, - 'roadtripBoundary:changed': { boundaries: [{ day_number: 1, from_assignment_id: 9, to_assignment_id: null, fraction: 1 }] }, + 'roadtripBoundary:changed': { + boundaries: [{ day_number: 1, from_assignment_id: 9, to_assignment_id: null, fraction: 1 }], + }, 'dayNote:created': { dayId: 2, note: { id: 5 } }, 'dayNote:updated': { dayId: 2, note: { id: 5 } }, 'dayNote:deleted': { noteId: 5, dayId: 2 }, @@ -141,12 +144,12 @@ const DRIFT_VARIANTS: Partial[]> }; describe('@trek/shared realtime event registry', () => { - it('WSEVT-REG-001: pins the authoritative inventory counts (72 trip + 32 user = 104)', () => { + it('WSEVT-REG-001: pins the authoritative inventory counts (72 trip + 33 user = 105)', () => { // 67th to 69th trip event: the three collab:link:* a shared link emits. // 70th and 71st: the road trip's vias and tracks, which used to be written silently. expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(72); - expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32); - expect(TREK_WS_EVENT_NAMES).toHaveLength(104); + expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(33); + expect(TREK_WS_EVENT_NAMES).toHaveLength(105); }); 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 7f15ab9d5..359b576ed 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -1,5 +1,7 @@ -import { z } from 'zod'; import { roadtripDayBoundaryListSchema } from '../roadtrip/day-boundary.schema'; +import { roadtripPreferencesSchema } from '../roadtrip/preferences.schema'; + +import { z } from 'zod'; /** * WS event contract registry — the single source of truth for every realtime @@ -257,6 +259,7 @@ export const TREK_WS_EVENTS = { 'memories:updated': { scope: 'trip', payload: z.object({ userId: id }) }, // ── Notifications (user-scoped) ────────────────────────────────────────── + 'roadtripPreferences:changed': { scope: 'user', payload: z.object({ preferences: roadtripPreferencesSchema }) }, 'notification:new': { scope: 'user', payload: z.object({ notification: entity }) }, 'notification:updated': { scope: 'user', payload: z.object({ notification: entity }) }, @@ -333,12 +336,14 @@ export const TREK_WS_EVENTS = { scope: 'user', payload: z.object({ journeyId: id, - peers: z.array(z.object({ - socketId: z.number(), - userId: id, - username: z.string(), - avatar: z.string().nullable().optional(), - })), + peers: z.array( + z.object({ + socketId: z.number(), + userId: id, + username: z.string(), + avatar: z.string().nullable().optional(), + }), + ), }), }, diff --git a/shared/src/roadtrip/accessSpur.ts b/shared/src/roadtrip/accessSpur.ts new file mode 100644 index 000000000..5bf212320 --- /dev/null +++ b/shared/src/roadtrip/accessSpur.ts @@ -0,0 +1,15 @@ +import type { SnappedWaypoint } from './planning-types'; + +export const ACCESS_SPUR_MIN_M = 30; + +export const ACCESS_LABEL_MIN_M = 250; + +export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null { + if (!snapped) return null; + if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null; + return [snapped.asked, snapped.at]; +} + +export function spurWorthLabelling(meters: number | null | undefined): boolean { + return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M; +} diff --git a/shared/src/roadtrip/assemble.ts b/shared/src/roadtrip/assemble.ts new file mode 100644 index 000000000..fa4b9bae9 --- /dev/null +++ b/shared/src/roadtrip/assemble.ts @@ -0,0 +1,253 @@ +import { spurFor } from './accessSpur'; +import { pointAtMeters } from './corridor'; +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import { planDayWindow, type DayWindow } from './dayWindow'; +import { spillChains } from './nightSpill'; +import type { + PlanDay, + QuietDay, + RoadtripStop, + RoadtripDay, + RoadtripRoutes, + RoutedLeg, + RouteSegment, + SnappedWaypoint, + RouteAvoidClass, + DistanceUnit, +} from './planning-types'; +import { + computeSchedule, + deriveDriveWarnings, + refuelsRange, + isServiceStopType, + type DryPoint, + type DriveLimits, + type VehicleKind, +} from './roadtripModel'; + +const stopKey = (s: RoadtripStop): string => + `${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`; + +const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`; + +export function assembleRoadtrip({ + plan, + quietDays, + window, + distanceUnit, + allLegs, + snapByDay, + missedByDay, + loading, + limits, + vehicleKind, + connectDays, + boundaries, + labels, +}: { + plan: PlanDay[]; + quietDays: QuietDay[]; + window: DayWindow | null; + distanceUnit: DistanceUnit; + allLegs: Record; + snapByDay: Record>; + missedByDay: Record; + loading: boolean; + limits: DriveLimits; + vehicleKind: VehicleKind | null; + connectDays: boolean; + boundaries: RoadtripDayBoundary[]; + labels: { start: string; end: string }; +}): RoadtripRoutes { + const chains = spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]); + + const allSnaps: Record = {}; + for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {}); + const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)]; + const timed = window + ? planDayWindow( + [...plan, ...quietDays], + window, + storedLegFor, + distanceUnit, + { start: labels.start, end: labels.end }, + boundaries, + ) + : null; + const automaticSchedule = !!timed && timed.issue === null; + const displayChains = automaticSchedule + ? timed.chains + : timed?.issue + ? [...plan, ...quietDays] + .sort((a, b) => a.dayNumber - b.dayNumber) + .filter((d) => d.stops.length) + .map((d) => ({ + ...d, + spills: [], + schedule: computeSchedule( + d.stops.map((s) => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })), + d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration), + ), + })) + : chains; + const legFor = automaticSchedule ? timed.legFor : storedLegFor; + + const lines: [number, number][][] = []; + const lineDays: number[] = []; + const segments: RouteSegment[] = []; + const accessLines: RoadtripRoutes['accessLines'] = []; + + const out: RoadtripDay[] = []; + let carryKm: number | null = 0; + + let previousStop: RoadtripStop | undefined; + + let previousDayNumber: number | undefined; + for (const chain of displayChains) { + const routed = chain.stops.slice(0, -1).map((s, i) => legFor(s, chain.stops[i + 1]!)); + + const inboundAt = new Map(); + if (connectDays && !automaticSchedule) { + for (const spill of chain.spills) { + inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber }); + } + + 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); + lineDays.push(chain.dayNumber); + } + segments.push(leg.seg); + } + + 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) + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0); + const schedule = chain.schedule; + const legVias = routed.map((l) => l?.vias ?? []); + const stops = chain.stops.map((s) => { + const snap = s.automaticNight ? undefined : 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 }; + }); + + 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, + + stops.map((s) => refuelsRange(s.stopType, vehicleKind)), + limits, + startKm, + + stops.map((s) => s.fillPercent), + ); + carryKm = drive.carryKm; + + 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); + + const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine; + out.push({ + automaticSchedule, + 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 || d.stops.some((s) => s.automaticNight)); + const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops); + const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => { + const to = originalStops[position + 1]!; + const leg = storedLegFor(from, to); + return leg && (!leg.seg.mode || leg.seg.mode === 'driving') && from.assignmentId > 0 && to.assignmentId > 0 + ? [{ from, to, position, line: leg.line }] + : []; + }); + return { + boundaryPath, + validateBoundaries: (next: RoadtripDayBoundary[]) => + window + ? planDayWindow( + [...plan, ...quietDays], + window, + storedLegFor, + distanceUnit, + { start: labels.start, end: labels.end }, + next, + ).issue + : 'conflict', + dayWindowIssue: timed?.issue ?? null, + days: drives, + lines, + lineDays, + segments, + accessLines, + vias: out.flatMap((d) => d.legVias.flat()), + totalDistance: drives.reduce((s, d) => s + d.distance, 0), + totalDuration: drives.reduce((s, d) => s + d.duration, 0), + + totalStops: drives.reduce( + (s, d) => s + d.stops.filter((st) => !st.automaticNight && !isServiceStopType(st.stopType)).length, + 0, + ), + + quietDays: out + .filter((d) => d.stops.length < 2 && !d.stops.some((s) => s.automaticNight)) + .map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })), + loading, + }; +} diff --git a/client/src/components/Roadtrip/corridor.test.ts b/shared/src/roadtrip/corridor.spec.ts similarity index 52% rename from client/src/components/Roadtrip/corridor.test.ts rename to shared/src/roadtrip/corridor.spec.ts index c05633492..31adf93a6 100644 --- a/client/src/components/Roadtrip/corridor.test.ts +++ b/shared/src/roadtrip/corridor.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { boxAround, corridorTiles, @@ -9,131 +8,147 @@ import { sliceAtMeters, simplifyLine, type LatLng, -} from './corridor' +} from './corridor'; -const BERLIN: LatLng = { lat: 52.52, lng: 13.405 } -const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 } -const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 } +import { describe, it, expect } from 'vitest'; + +const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }; +const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }; +const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }; describe('haversineKm', () => { it('measures a known distance', () => { // Berlin → Dresden is about 165 km as the crow flies. - expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160) - expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170) - }) + expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160); + expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170); + }); it('is zero for the same point', () => { - expect(haversineKm(BERLIN, BERLIN)).toBe(0) - }) -}) + expect(haversineKm(BERLIN, BERLIN)).toBe(0); + }); +}); describe('distanceToSegmentKm', () => { - const a: LatLng = { lat: 52, lng: 13 } - const b: LatLng = { lat: 52, lng: 14 } + const a: LatLng = { lat: 52, lng: 13 }; + const b: LatLng = { lat: 52, lng: 14 }; it('measures perpendicular distance to the line', () => { - const p: LatLng = { lat: 52.09, lng: 13.5 } + const p: LatLng = { lat: 52.09, lng: 13.5 }; // 0.09° of latitude is about 10 km, whatever the longitude does. - expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9) - expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11) - }) + expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9); + expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11); + }); it('measures to the end, not to the infinite line, for a point beyond it', () => { - const beyond: LatLng = { lat: 52, lng: 16 } - const d = distanceToSegmentKm(beyond, a, b) + const beyond: LatLng = { lat: 52, lng: 16 }; + const d = distanceToSegmentKm(beyond, a, b); // Two degrees of longitude past the end at 52° north — roughly 137 km. - expect(d).toBeGreaterThan(120) - expect(d).toBeLessThan(150) - }) + expect(d).toBeGreaterThan(120); + expect(d).toBeLessThan(150); + }); it('falls back to point distance for a zero-length segment', () => { - expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5) - }) -}) + expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5); + }); +}); describe('projectOntoRoute', () => { - const line = [BERLIN, DRESDEN, PRAGUE] + const line = [BERLIN, DRESDEN, PRAGUE]; it('reports how far off the route a point is', () => { - const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line) - expect(onRoute!.offRouteKm).toBeLessThan(15) - const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line) - expect(wayOff!.offRouteKm).toBeGreaterThan(150) - }) + const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line); + expect(onRoute!.offRouteKm).toBeLessThan(15); + const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line); + expect(wayOff!.offRouteKm).toBeGreaterThan(150); + }); it('orders points by how far into the drive they come', () => { - const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)! - const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)! - expect(early.alongKm).toBeLessThan(late.alongKm) - }) + const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!; + const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!; + expect(early.alongKm).toBeLessThan(late.alongKm); + }); it('tells two points on the same long straight apart', () => { // The petrol stations along one motorway segment must not all report the same // position in the drive, which is what taking the segment midpoint produced. - const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }] - const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)! - const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)! - expect(late.alongKm - early.alongKm).toBeGreaterThan(80) - }) + const straight = [ + { lat: 52, lng: 13 }, + { lat: 52, lng: 15 }, + ]; + const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!; + const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!; + expect(late.alongKm - early.alongKm).toBeGreaterThan(80); + }); it('returns nothing for a line that is not one', () => { - expect(projectOntoRoute(BERLIN, [])).toBeNull() - expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull() - }) -}) + expect(projectOntoRoute(BERLIN, [])).toBeNull(); + expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull(); + }); +}); describe('corridorTiles', () => { it('covers a short route with a single box', () => { - const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5) - expect(tiles).toHaveLength(1) - expect(tiles[0].south).toBeLessThan(52.4) - expect(tiles[0].north).toBeGreaterThan(52.52) - }) + const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5); + expect(tiles).toHaveLength(1); + expect(tiles[0]!.south).toBeLessThan(52.4); + expect(tiles[0]!.north).toBeGreaterThan(52.52); + }); it('splits a long route into boxes the server will not clamp', () => { - const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10) - expect(tiles.length).toBeGreaterThan(1) + const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10); + expect(tiles.length).toBeGreaterThan(1); for (const t of tiles) { - expect(t.north - t.south).toBeLessThanOrEqual(0.45) - expect(t.east - t.west).toBeLessThanOrEqual(0.45) + expect(t.north - t.south).toBeLessThanOrEqual(0.45); + expect(t.east - t.west).toBeLessThanOrEqual(0.45); } - }) + }); it('pads longitude more the further north it goes', () => { - const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0] - const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0] - expect(north.east - north.west).toBeGreaterThan(south.east - south.west) - }) + const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]!; + const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]!; + expect(north.east - north.west).toBeGreaterThan(south.east - south.west); + }); it('covers the middle of a long straight leg, not only its two ends', () => { // A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks // point to point, so without splitting the step everything between them goes // unsearched while the search still reports itself complete. - const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }] - const tiles = corridorTiles(straight, 10) + const straight = [ + { lat: 53.55, lng: 9.99 }, + { lat: 52.52, lng: 13.4 }, + ]; + const tiles = corridorTiles(straight, 10); - expect(tiles.length).toBeGreaterThan(4) + expect(tiles.length).toBeGreaterThan(4); // A box somewhere over the middle of the drive, which is what was missing. - const midLat = 53.03 - const midLng = 11.7 - expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true) - }) + const midLat = 53.03; + const midLng = 11.7; + expect(tiles.some((t) => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe( + true, + ); + }); it('leaves no gap between consecutive boxes along a route', () => { - const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10) + const tiles = corridorTiles( + [ + { lat: 53.55, lng: 9.99 }, + { lat: 52.52, lng: 13.4 }, + ], + 10, + ); for (let i = 1; i < tiles.length; i++) { - const a = tiles[i - 1] - const b = tiles[i] + const a = tiles[i - 1]!; + const b = tiles[i]!; // Overlapping or touching in both axes: a hole here is a stretch nobody looks at. - expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0) - expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0) + expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0); + expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0); } - }) + }); it('has nothing to cover for an empty route', () => { - expect(corridorTiles([], 10)).toEqual([]) - }) -}) + expect(corridorTiles([], 10)).toEqual([]); + }); +}); describe('simplifyLine', () => { it('keeps the ends and drops what sits on the line between them', () => { @@ -141,123 +156,130 @@ describe('simplifyLine', () => { { lat: 52, lng: 13 }, { lat: 52, lng: 13.5 }, { lat: 52, lng: 14 }, - ] - expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]]) - }) + ]; + expect(simplifyLine(straight, 1)).toEqual([straight[0]!, straight[2]!]); + }); it('keeps a point that actually bends the route', () => { const bent = [ { lat: 52, lng: 13 }, { lat: 52.5, lng: 13.5 }, { lat: 52, lng: 14 }, - ] - expect(simplifyLine(bent, 1)).toHaveLength(3) - }) + ]; + expect(simplifyLine(bent, 1)).toHaveLength(3); + }); it('leaves a two-point line alone', () => { - expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN]) - }) -}) + 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 + 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) - }) + 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)! + 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) - }) + 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) - }) + 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) - }) + 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) - }) -}) + 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) + 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) - }) + 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) - }) -}) + 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 }] + 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) - }) + 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) - }) + 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]) - }) + 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([]) - }) + 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([]) - }) + 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) - }) -}) + 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/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts new file mode 100644 index 000000000..eb922c163 --- /dev/null +++ b/shared/src/roadtrip/corridor.ts @@ -0,0 +1,226 @@ +export interface LatLng { + lat: number; + lng: number; +} + +export interface Bbox { + south: number; + west: number; + north: number; + east: number; +} + +const EARTH_RADIUS_KM = 6371; +const KM_PER_DEG_LAT = 111.32; + +const toRad = (deg: number): number => (deg * Math.PI) / 180; + +export function haversineKm(a: LatLng, b: LatLng): number { + const dLat = toRad(b.lat - a.lat); + const dLng = toRad(b.lng - a.lng); + const lat1 = toRad(a.lat); + const lat2 = toRad(b.lat); + const h = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2); + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number { + return projectOnSegment(p, a, b).distanceKm; +} + +const DEGENERATE_SEGMENT_KM2 = 1e-12; + +export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } { + const latScale = KM_PER_DEG_LAT; + const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2)); + const ax = a.lng * lngScale; + const ay = a.lat * latScale; + const bx = b.lng * lngScale; + const by = b.lat * latScale; + const px = p.lng * lngScale; + const py = p.lat * latScale; + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + + if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }; + + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq)); + const cx = ax + t * dx; + const cy = ay + t * dy; + return { distanceKm: Math.hypot(px - cx, py - cy), t }; +} + +export interface CorridorHit { + offRouteKm: number; + + alongKm: number; +} + +export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null { + if (line.length < 2) return null; + let best = Number.POSITIVE_INFINITY; + let bestAlong = 0; + let travelled = 0; + for (let i = 0; i < line.length - 1; i++) { + const a = line[i]!; + const b = line[i + 1]!; + const segment = haversineKm(a, b); + const { distanceKm: d, t } = projectOnSegment(p, a, b); + if (d < best) { + best = d; + bestAlong = travelled + t * segment; + } + travelled += segment; + } + return { offRouteKm: best, alongKm: bestAlong }; +} + +function densify(line: LatLng[], maxStepDeg: number): LatLng[] { + if (line.length < 2) return line; + const out: LatLng[] = [line[0]!]; + for (let i = 1; i < line.length; i++) { + const a = line[i - 1]!; + const b = line[i]!; + const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng)); + const steps = Math.max(1, Math.ceil(span / maxStepDeg)); + for (let s = 1; s <= steps; s++) { + out.push({ + lat: a.lat + ((b.lat - a.lat) * s) / steps, + lng: a.lng + ((b.lng - a.lng) * s) / steps, + }); + } + } + return out; +} + +export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] { + if (line.length === 0) return []; + const padLat = widthKm / KM_PER_DEG_LAT; + const tiles: Bbox[] = []; + let current: Bbox | null = null; + + const padded = (b: Bbox): Bbox => { + const midLat = (b.north + b.south) / 2; + const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat))); + return { + south: b.south - padLat, + north: b.north + padLat, + west: b.west - padLng, + east: b.east + padLng, + }; + }; + + let previous: LatLng | null = null; + + for (const p of densify(line, maxSpanDeg / 2)) { + if (!current) { + current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }; + previous = p; + continue; + } + const next: Bbox = { + south: Math.min(current.south, p.lat), + north: Math.max(current.north, p.lat), + west: Math.min(current.west, p.lng), + east: Math.max(current.east, p.lng), + }; + const grown = padded(next); + if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) { + tiles.push(padded(current)); + const from = previous ?? p; + current = { + south: Math.min(from.lat, p.lat), + north: Math.max(from.lat, p.lat), + west: Math.min(from.lng, p.lng), + east: Math.max(from.lng, p.lng), + }; + } else { + current = next; + } + previous = p; + } + if (current) tiles.push(padded(current)); + return tiles; +} + +export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] { + if (line.length < 3) return line.slice(); + let maxDist = 0; + let index = 0; + const first = line[0]!; + const last = line[line.length - 1]!; + for (let i = 1; i < line.length - 1; i++) { + const d = distanceToSegmentKm(line[i]!, first, last); + if (d > maxDist) { + maxDist = d; + index = i; + } + } + if (maxDist <= toleranceKm) return [first, last]; + const left = simplifyLine(line.slice(0, index + 1), toleranceKm); + const right = simplifyLine(line.slice(index), toleranceKm); + return [...left.slice(0, -1), ...right]; +} + +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) { + 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]!; +} + +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; +} + +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; + + 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 : []; +} + +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/shared/src/roadtrip/day-boundary.schema.ts b/shared/src/roadtrip/day-boundary.schema.ts index 88397c55d..8be316b0d 100644 --- a/shared/src/roadtrip/day-boundary.schema.ts +++ b/shared/src/roadtrip/day-boundary.schema.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; -export const roadtripDayBoundarySchema = z.object({ - day_number: z.number().int().min(1).max(366), - from_assignment_id: z.number().int().positive(), - to_assignment_id: z.number().int().positive().nullable(), - fraction: z.number().min(0).max(1), -}).refine(b => b.to_assignment_id !== b.from_assignment_id, 'A boundary needs two different stops'); +export const roadtripDayBoundarySchema = z + .object({ + day_number: z.number().int().min(1).max(366), + from_assignment_id: z.number().int().positive(), + to_assignment_id: z.number().int().positive().nullable(), + fraction: z.number().min(0).max(1), + }) + .refine((b) => b.to_assignment_id !== b.from_assignment_id, 'A boundary needs two different stops'); export const roadtripDayBoundaryListSchema = z.object({ boundaries: z.array(roadtripDayBoundarySchema), diff --git a/shared/src/roadtrip/dayWindow.spec.ts b/shared/src/roadtrip/dayWindow.spec.ts new file mode 100644 index 000000000..8199bd0f2 --- /dev/null +++ b/shared/src/roadtrip/dayWindow.spec.ts @@ -0,0 +1,367 @@ +import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow'; +import type { RoadtripStop, RoutedLeg } from './planning-types'; + +import { describe, expect, it } from 'vitest'; + +const hours = { start: 480, end: 1080 }; +const labels = { start: 'Continue', end: 'End of day' }; +const stop = (id: number, patch: Partial = {}): RoadtripStop => ({ + assignmentId: id, + placeId: id, + ownerDayId: 1, + ownerIndex: id - 1, + lat: 0, + lng: id - 1, + name: `Stop ${id}`, + time: null, + dwellMinutes: 0, + legMode: null, + incomingLegMode: null, + stopType: null, + ...patch, +}); +const day = (number: number, stops: RoadtripStop[] = []) => ({ + dayId: number, + dayNumber: number, + date: `2026-09-${String(number + 10).padStart(2, '0')}`, + title: null, + stops, +}); +const leg = (minutes: number, from: RoadtripStop, to: RoadtripStop): RoutedLeg => ({ + line: [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + vias: [], + seg: { + from: [from.lat, from.lng], + to: [to.lat, to.lng], + mid: [0, (from.lng + to.lng) / 2], + distance: minutes * 1000, + duration: minutes * 60, + mode: 'driving', + distanceText: '', + drivingText: '', + walkingText: '', + }, +}); +const calculate = (stops: RoadtripStop[], minutes: number[] = [720], extra = [day(2)]) => + planDayWindow( + [day(1, stops), ...extra], + hours, + (a, b) => leg(minutes[stops.indexOf(a)]! ?? 60, a, b), + 'metric', + labels, + ); + +describe('daily travel window', () => { + it('pulls an unpinned visit back into day one and ends after its full stay', () => { + const stops = [stop(1, { time: '07:00' }), stop(2, { dwellMinutes: 90 }), stop(3)]; + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(720, a, b); + const automatic = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels); + expect(automatic.chains[0]!.stops).not.toContain(stops[1]!); + const manual = planDayWindow([day(1, stops)], hours, lookup, 'metric', labels, [ + { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }, + ]); + expect(manual.issue).toBeNull(); + expect(manual.chains[0]!.stops).toContain(stops[1]!); + expect(manual.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '19:00', '20:30']); + expect(manual.chains[1]!.schedule.entries[0]!.arrival).toBe('08:00'); + expect(stops[0]!.time).toBe('07:00'); + }); + + it.each([0.25, 0.9])('moves the cut in either direction and preserves the whole drive (%s)', (fraction) => { + const stops = [stop(1), stop(2)]; + const manual = planDayWindow([day(1, stops)], hours, (a, b) => leg(720, a, b), 'metric', labels, [ + { day_number: 1, from_assignment_id: 1, to_assignment_id: 2, fraction }, + ]); + expect(manual.issue).toBeNull(); + const end = manual.chains[0]!.stops[1]!; + expect(end.lng).toBeCloseTo(fraction); + expect(end.automaticNight).toMatchObject({ manual: true, position: fraction }); + const start = manual.chains[1]!.stops[0]!; + expect(manual.legFor(stops[0]!, end)!.seg.duration + manual.legFor(start, stops[1]!)!.seg.duration).toBeCloseTo( + 720 * 60, + ); + }); + + it('does not pull a visit with a fixed time out of its stored day', () => { + const pinned = stop(2, { ownerDayId: 2, time: '12:00' }); + const manual = planDayWindow( + [day(1, [stop(1)]), day(2, [pinned])], + hours, + (a, b) => leg(60, a, b), + 'metric', + labels, + [{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }], + ); + expect(manual.issue).toBe('conflict'); + expect(pinned.time).toBe('12:00'); + }); + + it('rejects crossed or disconnected manual boundaries', () => { + const stops = [stop(1), stop(2), stop(3)]; + for (const boundaries of [ + [{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 }], + [ + { day_number: 1, from_assignment_id: 3, to_assignment_id: null, fraction: 1 }, + { day_number: 2, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }, + ], + ]) + expect(planDayWindow([day(1, stops)], hours, (a, b) => leg(60, a, b), 'metric', labels, boundaries).issue).toBe( + 'conflict', + ); + }); + + it('mixes an explicit stop end with automatic cuts on later drives', () => { + const visited = stop(2, { endDay: true, dwellMinutes: 60 }); + const planned = calculate([stop(1), visited, stop(3)], [120, 720], []); + expect(planned.issue).toBeNull(); + expect(planned.chains.map((d) => d.schedule.entries.map((e) => e.arrival))).toEqual([ + ['08:00', '10:00', '11:00'], + ['08:00', '18:00'], + ['08:00', '10:00'], + ]); + expect(planned.chains[0]!.stops[2]!).toMatchObject({ lat: visited.lat, lng: visited.lng }); + expect(visited.endDay).toBe(true); + }); + + it('removing an explicit end restores the automatic schedule without adding a final night', () => { + const planned = calculate([stop(1), stop(2, { endDay: false }), stop(3, { endDay: true })], [120, 60], []); + expect(planned.chains).toHaveLength(1); + expect(planned.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00', '11:00']); + }); + + it('keeps manual times when an explicit end would move their day', () => { + const pinned = stop(2, { time: '12:00' }); + const planned = calculate([stop(1, { endDay: true }), pinned], [60], []); + expect(planned.issue).toBe('conflict'); + expect(pinned.time).toBe('12:00'); + }); + + it('honors an earlier manual arrival on the following stored day', () => { + const first = stop(1, { endDay: true }); + const next = stop(2, { time: '07:00', ownerDayId: 2 }); + const planned = planDayWindow([day(1, [first]), day(2, [next])], hours, (a, b) => leg(60, a, b), 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00']); + expect(next.time).toBe('07:00'); + }); + + it('requires two valid increasing HH:mm values', () => { + expect(dayWindow('08:00', '18:00')).toEqual(hours); + expect(dayWindow('00:00', '00:01')).toEqual({ start: 0, end: 1 }); + for (const pair of [ + [null, '18:00'], + ['08:00', ''], + ['8:00', '18:00'], + ['24:00', '25:00'], + ['18:00', '08:00'], + ['08:00', '08:00'], + [800, 1800], + ]) { + expect(dayWindow(...(pair as [unknown, unknown]))).toBeNull(); + } + }); + + it('splits the drive at day end and resumes at the same point next morning', () => { + const first = stop(1); + const last = stop(2, { lng: 12 }); + const planned = calculate([first, last]); + expect(planned.issue).toBeNull(); + const today = planned.chains[0]!; + const tomorrow = planned.chains[1]!; + const end = today.stops[1]!; + const start = tomorrow.stops[0]!; + expect(today.schedule.entries[0]!.arrival).toBe('08:00'); + expect(today.schedule.entries[1]!.arrival).toBe('18:00'); + expect(end.lng).toBeCloseTo(10); + expect([start.lat, start.lng]).toEqual([end.lat, end.lng]); + expect(tomorrow.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00']); + expect(tomorrow.stops[1]!).toBe(last); + expect(tomorrow.spills[0]!).toMatchObject({ automatic: true, fromDayNumber: 1, at: 1 }); + const before = planned.legFor(first, end)!; + const after = planned.legFor(start, last)!; + expect(before.seg.duration + after.seg.duration).toBe(720 * 60); + expect(before.seg.distance + after.seg.distance).toBe(720000); + expect(before.line[before.line.length - 1]!).toEqual(after.line[0]!); + expect(first.time).toBeNull(); + expect(last.ownerDayId).toBe(1); + }); + + it('ends at the last visited place after its stay when the next drive will not fit', () => { + const first = stop(1); + const visited = stop(2, { dwellMinutes: 60 }); + const next = stop(3); + const route = [first, visited, next]; + const lookup = (a: RoadtripStop, b: RoadtripStop) => leg(a === first ? 480 : 120, a, b); + const planned = planDayWindow([day(1, route)], { ...hours, endMode: 'stop' }, lookup, 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '16:00', '17:00']); + expect(planned.chains[0]!.stops[2]!).toMatchObject({ lat: visited.lat, lng: visited.lng }); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '10:00']); + expect(planned.legFor(planned.chains[1]!.stops[0]!, next)?.seg.duration).toBe(7200); + expect(visited.dwellMinutes).toBe(60); + expect(next.ownerDayId).toBe(1); + const alongRoute = planDayWindow([day(1, route)], hours, lookup, 'metric', labels); + expect(alongRoute.chains[0]!.schedule.entries[2]!.arrival).toBe('18:00'); + expect(alongRoute.chains[1]!.schedule.entries[1]!.arrival).toBe('09:00'); + }); + + it('reaches a last place exactly at the cutoff and still respects manual times', () => { + const first = stop(1, { time: '07:00' }); + const next = stop(2); + const calculateAtStop = (minutes: number) => + planDayWindow( + [day(1, [first, next])], + { ...hours, endMode: 'stop' }, + (a, b) => leg(minutes, a, b), + 'metric', + labels, + ); + expect(calculateAtStop(660).chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '18:00']); + next.time = '19:00'; + expect(calculateAtStop(720).chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['07:00', '19:00']); + expect(first.time).toBe('07:00'); + }); + + it('reports an indivisible drive that cannot fit rather than adding endless empty days', () => { + const planned = planDayWindow( + [day(1, [stop(1), stop(2)])], + { ...hours, endMode: 'stop' }, + (a, b) => leg(720, a, b), + 'metric', + labels, + ); + expect(planned.issue).toBe('legTooLong'); + expect(planned.chains).toEqual([]); + expect(dayWindow('08:00', '18:00', 'stop')).toEqual({ ...hours, endMode: 'stop' }); + expect(dayWindow('08:00', '18:00', 'unknown')).toEqual(hours); + }); + + it('starts at a manually pinned 07:00 despite an automatic 08:00 start', () => { + const first = stop(1, { time: '07:00' }); + const planned = calculate([first, stop(2, { lng: 12 })]); + expect(planned.chains[0]!.schedule.entries[0]!).toMatchObject({ arrival: '07:00', anchored: true }); + expect(planned.chains[0]!.stops[1]!.lng).toBeCloseTo(11); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('09:00'); + expect(first.time).toBe('07:00'); + }); + + it('accounts for stays and recalculates the pause position without mutating places', () => { + const first = stop(1, { dwellMinutes: 120 }); + const destination = stop(2, { lng: 12 }); + const planned = calculate([first, destination]); + expect(planned.chains[0]!.stops[1]!.lng).toBeCloseTo(8); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('12:00'); + const shortened = calculate([{ ...first, dwellMinutes: 0 }, destination]); + expect(shortened.chains[0]!.stops[1]!.lng).toBeCloseTo(10); + const shortDrive = calculate([first, destination], [120]); + expect(shortDrive.chains.flatMap((d) => d.stops).some((s) => s.automaticNight)).toBe(false); + expect(first.dwellMinutes).toBe(120); + }); + + it('does not move an arrival exactly at day end to tomorrow', () => { + const last = stop(2); + const planned = calculate([stop(1), last], [600]); + expect(planned.chains[0]!.stops).toHaveLength(2); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('18:00'); + expect(planned.chains[1]!.stops).toEqual([]); + }); + + it('continues across multiple nights and creates preview dates beyond the trip', () => { + const planned = calculate([stop(1), stop(2, { lng: 30 })], [1800], []); + expect(planned.chains.map((d) => d.date)).toEqual(['2026-09-11', '2026-09-12', '2026-09-13']); + expect(planned.chains[2]!.schedule.entries[1]!.arrival).toBe('18:00'); + expect(planned.chains[2]!.dayId).toBeLessThan(0); + expect(planned.chains.flatMap((d) => d.stops).filter((s) => !s.automaticNight)).toHaveLength(2); + }); + + it('keeps fixed appointments and reports an impossible schedule without partial relocation', () => { + const fixed = stop(2, { time: '10:00' }); + const impossible = calculate([stop(1), fixed], [180]); + expect(impossible.issue).toBe('conflict'); + expect(impossible.chains).toEqual([]); + expect(fixed.time).toBe('10:00'); + const possible = calculate([stop(1), fixed], [60]); + expect(possible.chains[0]!.schedule.entries[1]!).toMatchObject({ arrival: '10:00', anchored: true }); + }); + + it('allows a manual late appointment to override the automatic end', () => { + const fixed = stop(2, { time: '19:00' }); + const planned = calculate([stop(1), fixed], [660]); + expect(planned.issue).toBeNull(); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('19:00'); + expect(planned.chains[0]!.stops).toHaveLength(2); + }); + + it('keeps a pinned next-day start at 07:00 and leaves the pause point early enough', () => { + const a = stop(1); + const b = stop(2, { ownerDayId: 2, ownerIndex: 0, time: '07:00' }); + const planned = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00']); + }); + + it('does not silently move a fixed appointment onto a later date after a previous spill', () => { + const planned = calculate([stop(1), stop(2), stop(3, { time: '17:00' })], [720, 60]); + expect(planned.issue).toBe('conflict'); + }); + + it('pauses a long visit at its location and carries the remaining stay into tomorrow', () => { + const first = stop(1, { dwellMinutes: 660 }); + const planned = calculate([first, stop(2)], [60]); + expect(planned.chains[0]!.stops[1]!).toMatchObject({ lat: first.lat, lng: first.lng }); + expect(planned.chains[0]!.schedule.entries[0]!.departure).toBe('18:00'); + expect(planned.chains[1]!.schedule.entries[0]!.departure).toBe('09:00'); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00'); + expect(first.dwellMinutes).toBe(660); + }); + + it('does not place a night pause halfway through a ferry crossing', () => { + const a = stop(1); + const b = stop(2); + const ferry = leg(720, a, b); + ferry.seg.mode = 'ferry'; + const planned = planDayWindow([day(1, [a, b])], hours, () => ferry, 'metric', labels); + expect(planned.chains[0]!.stops).toHaveLength(2); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('20:00'); + }); + + it('does not invent times or points for missing routes', () => { + const planned = planDayWindow([day(1, [stop(1), stop(2)])], hours, () => undefined, 'metric', labels); + expect(planned.issue).toBe('incomplete'); + expect(planned.chains).toEqual([]); + expect(planDayWindow([], hours, () => undefined, 'metric', labels).chains).toEqual([]); + }); + + it('bounds extreme driving and stay durations', () => { + expect(calculate([stop(1), stop(2)], [600 * 367]).issue).toBe('tooLong'); + expect(calculate([stop(1, { dwellMinutes: 600 * 367 }), stop(2)], [1]).issue).toBe('tooLong'); + }); + + it('maps insertions on generated days back to real stored stops', () => { + const planned = calculate([stop(1), stop(2)]); + expect(roadtripInsertion(planned.chains[1]!, 0)).toEqual({ dayId: 1, position: 1 }); + expect(roadtripInsertion(planned.chains[1]!, 1)).toEqual({ dayId: 1, position: 1 }); + expect(roadtripInsertion({ stops: [] }, 0)).toBeNull(); + expect(roadtripInsertion(planned.chains[1]!, 2)).toEqual({ dayId: 1, position: 2 }); + }); + + it('rounds fractional route minutes across the hour boundary', () => { + const planned = calculate([stop(1), stop(2)], [59.8]); + expect(planned.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00'); + }); + + it('keeps a zero-distance timed leg at its location when split', () => { + const a = stop(1); + const b = stop(2, { lng: a.lng }); + const timed = leg(720, a, b); + timed.seg.distance = 0; + timed.line = []; + const planned = planDayWindow([day(1, [a, b])], hours, () => timed, 'metric', labels); + expect(planned.issue).toBeNull(); + expect(planned.chains[1]!.stops[0]!).toMatchObject({ lat: a.lat, lng: a.lng }); + expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00'); + }); +}); diff --git a/shared/src/roadtrip/dayWindow.ts b/shared/src/roadtrip/dayWindow.ts new file mode 100644 index 000000000..f9c94bf25 --- /dev/null +++ b/shared/src/roadtrip/dayWindow.ts @@ -0,0 +1,334 @@ +import { lineMetres, projectOntoRoute, sliceAtMeters } from './corridor'; +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import type { SpillChain } from './nightSpill'; +import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types'; +import type { DistanceUnit } from './planning-types'; +import { formatClock, parseClock } from './roadtripModel'; +import { formatDurationShort } from './roadtripModel'; +import { formatDistance } from './units'; + +export interface DayWindow { + start: number; + end: number; + endMode?: 'route' | 'stop'; +} + +export interface AutomaticNight { + phase: 'start' | 'end'; + fromDayNumber: number; + position?: number; + manual?: boolean; +} + +type PlanDay = Pick; +type LegLookup = (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined; + +export function dayWindow(start: unknown, end: unknown, endMode?: unknown): DayWindow | null { + const minutes = (clock: unknown): number | null => { + if (typeof clock !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(clock)) return null; + return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3)); + }; + const from = minutes(start); + const to = minutes(end); + return from !== null && to !== null && to > from + ? { start: from, end: to, ...(endMode === 'stop' ? ({ endMode } as const) : {}) } + : null; +} + +function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit): RoutedLeg { + if (from === 0 && to === 1) return leg; + const coordinates = leg.line.length ? leg.line : [leg.seg.from, leg.seg.to]; + const spine = coordinates.map(([lat, lng]) => ({ lat, lng })); + const metres = lineMetres(spine); + const line = + metres === 0 + ? coordinates + : sliceAtMeters(spine, from * metres, to * metres).map((p) => [p.lat, p.lng] as [number, number]); + const distance = leg.seg.distance * (to - from); + const duration = leg.seg.duration * (to - from); + const durationText = formatDurationShort(duration); + const distanceText = formatDistance(distance / 1000, unit); + return { + seg: { + ...leg.seg, + distance, + duration, + distanceText, + durationText, + drivingText: `${distanceText} · ${durationText}`, + walkingText: `${distanceText} · ${durationText}`, + from: line[0]!, + to: line[line.length - 1]!, + mid: line[Math.floor(line.length / 2)]!, + }, + line, + vias: leg.vias.filter((v) => { + const along = (projectOntoRoute(v, spine)?.alongKm ?? 0) * 1000; + return along >= from * metres && (to === 1 ? along <= metres : along < to * metres); + }), + }; +} + +function stationary(stop: RoadtripStop): RoutedLeg { + const at: [number, number] = [stop.lat, stop.lng]; + return { + line: [], + vias: [], + seg: { + from: at, + to: at, + mid: at, + distance: 0, + duration: 0, + mode: 'driving', + walkingText: '', + drivingText: '', + distanceText: '', + }, + }; +} + +export interface WindowPlan { + chains: SpillChain[]; + legFor: LegLookup; + issue: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null; +} + +export function planDayWindow( + days: PlanDay[], + window: DayWindow, + lookup: LegLookup, + unit: DistanceUnit, + labels: { start: string; end: string }, + boundaries: RoadtripDayBoundary[] = [], +): WindowPlan { + const ordered = [...days].sort((a, b) => a.dayNumber - b.dayNumber); + const stops = ordered.flatMap((day) => day.stops.map((stop) => ({ stop, day }))); + const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue }); + if (!stops.length) return { chains: [], legFor: lookup, issue: null }; + const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i])); + const targets = new Map(); + let lastTarget = -1; + for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) { + const from = positions.get(boundary.from_assignment_id); + const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id); + if (from === undefined || (to !== null && to !== from + 1)) return failed('conflict'); + const target = from + (to === null ? 0 : boundary.fraction); + if (target <= lastTarget) return failed('conflict'); + targets.set(boundary.day_number, target); + lastTarget = target; + } + const legs = stops.slice(0, -1).map((s, i) => lookup(s.stop, stops[i + 1]!.stop)); + if ( + legs.some( + (l) => !l || !Number.isFinite(l.seg.duration) || l.seg.duration < 0 || (l.line.length < 2 && l.seg.distance > 0), + ) + ) { + return failed('incomplete'); + } + + const chains = new Map(); + const pieces = new Map>(); + const firstDay = stops[0]!.day.dayNumber; + let number = firstDay; + let clock = window.start; + let issue: WindowPlan['issue'] = null; + let position = 0; + + const chainAt = (n: number): SpillChain => { + const existing = chains.get(n); + if (existing) return existing; + const stored = ordered.find((d) => d.dayNumber === n); + const dated = ordered.find((d) => d.date); + let date = stored?.date ?? null; + if (!stored && dated?.date) { + const dayDate = new Date(`${dated.date}T12:00:00Z`); + dayDate.setUTCDate(dayDate.getUTCDate() + n - dated.dayNumber); + date = dayDate.toISOString().slice(0, 10); + } + const chain: SpillChain = { + dayId: stored?.dayId ?? -1000000000 - n, + dayNumber: n, + date, + title: stored?.title ?? null, + stops: [], + schedule: { entries: [], warnings: [] }, + spills: [], + }; + chains.set(n, chain); + return chain; + }; + const putLeg = (from: RoadtripStop, to: RoadtripStop, leg: RoutedLeg) => { + if (!pieces.has(from)) pieces.set(from, new Map()); + pieces.get(from)!.set(to, leg); + }; + const append = (stop: RoadtripStop, arrival: number, departure = arrival) => { + const chain = chainAt(number); + chain.stops.push(stop); + chain.schedule.entries.push({ + arrival: formatClock(Math.round(arrival)), + departure: formatClock(Math.round(departure)), + anchored: !stop.automaticNight && stop.time !== null, + dayOffset: Math.floor(arrival / 1440), + }); + }; + const night = (at: RoadtripStop, final = false): RoadtripStop => { + const chain = chainAt(number); + const last = chain.stops[chain.stops.length - 1]!; + const end: RoadtripStop = { + ...at, + assignmentId: -2000000000 - number * 2, + name: labels.end, + time: null, + dwellMinutes: 0, + stopType: null, + fillPercent: null, + automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) }, + }; + append(end, clock); + if (last) putLeg(last, end, stationary(at)); + if (final) return end; + number += 1; + if (number - firstDay > 365) issue = 'tooLong'; + clock = window.start; + const start: RoadtripStop = { + ...end, + assignmentId: end.assignmentId - 1, + name: labels.start, + automaticNight: { ...end.automaticNight!, phase: 'start' }, + }; + append(start, clock); + return start; + }; + + let previous: RoadtripStop | undefined; + for (let i = 0; i < stops.length; i++) { + const { stop, day } = stops[i]!; + const pin = parseClock(stop.time); + if (previous && number < day.dayNumber && !targets.has(number)) { + if (window.endMode !== 'stop') clock = Math.max(clock, window.end); + previous = night(previous); + while (number < day.dayNumber && !issue) { + clock = window.end; + previous = night(previous); + } + } else if (!previous) { + number = day.dayNumber; + clock = pin ?? window.start; + } + if (issue) return failed(issue); + if (pin !== null && day.dayNumber !== number) return failed('conflict'); + + if (previous) { + const leg = legs[i - 1]!; + const minutes = leg.seg.duration / 60; + if (pin !== null && chainAt(number).stops.every((s) => s.automaticNight)) { + clock = Math.min(clock, pin - minutes); + if (clock < 0) return failed('conflict'); + const startEntry = chainAt(number).schedule.entries[0]!; + startEntry.arrival = formatClock(Math.round(clock)); + startEntry.departure = formatClock(Math.round(clock)); + } + if (pin !== null && clock + minutes > pin + 1) return failed('conflict'); + + let fraction = 0; + const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined); + if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) { + if (minutes > window.end - window.start) return failed('legTooLong'); + previous = night(previous); + if (issue) return failed(issue); + } + while (fraction < 1) { + const target = targets.get(number); + if (target !== undefined && target < i - 1 + fraction - 0.000001) return failed('conflict'); + const manualCut = target !== undefined && target < i && target >= i - 1 + fraction; + if (!manualCut && (target !== undefined || !split || clock + minutes * (1 - fraction) <= window.end + 0.000001)) + break; + const available = manualCut ? minutes * (target - (i - 1 + fraction)) : Math.max(0, window.end - clock); + if (available === 0) { + previous = night(previous); + } else { + const until = Math.min(1, fraction + available / minutes); + const part = portion(leg, fraction, until, unit); + const at = part.line[part.line.length - 1]!; + const point = { ...previous, lat: at[0], lng: at[1] }; + clock += available; + position = i - 1 + until; + const before = previous; + previous = night(point); + const previousStops = chainAt(number - 1).stops; + const ending = previousStops[previousStops.length - 1]!; + putLeg(before, ending, part); + fraction = until; + } + if (issue) return failed(issue); + } + const remainder = portion(leg, fraction, 1, unit); + clock += minutes * (1 - fraction); + putLeg(previous, stop, remainder); + } + position = i; + if (pin !== null && number !== day.dayNumber) return failed('conflict'); + const target = targets.get(number); + if (target !== undefined && target < position) return failed('conflict'); + if (pin !== null) clock = pin; + append(stop, clock); + previous = stop; + + let dwell = Math.max(0, stop.dwellMinutes ?? 0); + while (dwell > 0) { + const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock); + const spend = Math.min(dwell, remaining); + clock += spend; + dwell -= spend; + const entries = chainAt(number).schedule.entries; + entries[entries.length - 1]!.departure = formatClock(Math.round(clock)); + if (dwell > 0) { + previous = night(previous); + if (issue) return failed(issue); + } + } + if (target === position || (!targets.has(number) && stop.endDay && i < stops.length - 1)) { + previous = night(previous, i === stops.length - 1); + if (issue) return failed(issue); + } + } + + if ([...targets.keys()].some((day) => !chains.get(day)?.stops.some((stop) => stop.automaticNight?.phase === 'end'))) + return failed('conflict'); + + for (const chain of chains.values()) { + for (let i = 0; i < chain.stops.length; i++) { + const stop = chain.stops[i]!; + if (stop.automaticNight) continue; + const owner = ordered.find((d) => d.dayId === stop.ownerDayId); + if (!owner || owner.dayNumber === chain.dayNumber) continue; + const last = chain.spills[chain.spills.length - 1]!; + if (last?.fromDayNumber === owner.dayNumber && last.at + last.count === i) last.count += 1; + else + chain.spills.push({ + automatic: true, + at: i, + count: 1, + fromDayNumber: owner.dayNumber, + departure: null, + leg: undefined, + line: [], + fromStop: undefined, + }); + } + } + for (const day of ordered) chainAt(day.dayNumber); + return { + chains: [...chains.values()].sort((a, b) => a.dayNumber - b.dayNumber), + legFor: (from, to) => pieces.get(from)?.get(to), + issue: null, + }; +} + +export function roadtripInsertion(day: Pick, index: number) { + const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]!; + return stop + ? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) } + : null; +} diff --git a/client/src/components/Roadtrip/nightSpill.test.ts b/shared/src/roadtrip/nightSpill.spec.ts similarity index 61% rename from client/src/components/Roadtrip/nightSpill.test.ts rename to shared/src/roadtrip/nightSpill.spec.ts index 4773bcfa3..3093959c1 100644 --- a/client/src/components/Roadtrip/nightSpill.test.ts +++ b/shared/src/roadtrip/nightSpill.spec.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest' -import { spillChains } from './nightSpill' -import type { QuietDay, RoadtripDay, RoadtripStop } from './useRoadtripRoutes' -import type { RouteSegment } from '../../types' +import { spillChains } from './nightSpill'; +import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; +import type { RouteSegment } from './planning-types'; -type PlanDay = Pick +import { describe, it, expect } from 'vitest'; + +type PlanDay = Pick; function stop(over: Partial & { assignmentId: number; name: string }): RoadtripStop { return { @@ -18,7 +19,7 @@ function stop(over: Partial & { assignmentId: number; name: string incomingLegMode: null, stopType: null, ...over, - } + }; } /** A day of the plan, with every stop's stored position filled in for it. */ @@ -29,31 +30,47 @@ function day(dayId: number, dayNumber: number, stops: RoadtripStop[]): PlanDay { 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 + ({ + 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][] }) +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 +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 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)) + 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([]) - }) + 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. @@ -66,13 +83,13 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), stop({ assignmentId: 4, name: 'Leipzig' }), ]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + 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']) - }) + 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 = [ @@ -84,18 +101,18 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' }), stop({ assignmentId: 4, name: 'Leipzig' }), ]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180, 240)) + 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' }) + 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) - }) + 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 = [ @@ -104,30 +121,32 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(8, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + 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) - }) + 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 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)) + 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([]) + 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) - }) + 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 @@ -143,15 +162,15 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Leipzig' }), ]), day(2, 2, [stop({ assignmentId: 4, name: 'Dresden', time: '12:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(50)) + 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']) + 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']) - }) + 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 @@ -165,14 +184,14 @@ describe('spillChains', () => { ]), day(2, 2, []), day(3, 3, []), - ] + ]; - const chains = spillChains(plan, [], everyLeg(12 * 60)) + 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']) - }) + 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 = [ @@ -181,28 +200,30 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], noLegs) + 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([]) - }) + 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 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)) + 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']) - }) + 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 = [ @@ -211,15 +232,15 @@ describe('spillChains', () => { stop({ assignmentId: 2, name: 'Neuruppin' }), ]), day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + 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) - }) + 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" @@ -230,14 +251,14 @@ describe('spillChains', () => { 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 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) - }) + 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 = [ @@ -247,12 +268,12 @@ describe('spillChains', () => { stop({ assignmentId: 3, name: 'Potsdam' }), ]), day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]), - ] + ]; - const chains = spillChains(plan, [], everyLeg(180)) + 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 }) - }) -}) + 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/shared/src/roadtrip/nightSpill.ts b/shared/src/roadtrip/nightSpill.ts new file mode 100644 index 000000000..7dc01438d --- /dev/null +++ b/shared/src/roadtrip/nightSpill.ts @@ -0,0 +1,159 @@ +import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types'; +import type { RouteSegment } from './planning-types'; +import { computeSchedule, parseClock, type Schedule } from './roadtripModel'; + +export interface SpillMark { + automatic?: boolean; + + at: number; + + count: number; + + fromDayNumber: number; + + departure: string | null; + + leg: RouteSegment | undefined; + + fromStop: RoadtripStop | undefined; + + line: [number, number][]; +} + +export interface SpillChain { + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + + stops: RoadtripStop[]; + + schedule: Schedule; + + spills: SpillMark[]; +} + +type PlanDay = Pick; + +type LegLookup = (from: RoadtripStop, to: RoadtripStop) => { seg: RouteSegment; line: [number, number][] } | undefined; + +interface Placed { + stop: RoadtripStop; + arrival: string | null; + departure: string | null; + + dayOffset: number; + + codes: ('late' | 'overnight' | 'leg' | 'range')[]; + lateMinutes: number | null; + + fromDayNumber: number | null; + departedAt: string | null; + leg: RouteSegment | undefined; + line: [number, number][]; + from: RoadtripStop | undefined; +} + +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) { + 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), + ); + + 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); + + 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, + + dayOffset: moved ? 0 : offset, + + 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; + + 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/shared/src/roadtrip/planning-types.ts b/shared/src/roadtrip/planning-types.ts new file mode 100644 index 000000000..b91818210 --- /dev/null +++ b/shared/src/roadtrip/planning-types.ts @@ -0,0 +1,170 @@ +import type { RoadtripDayBoundary } from './day-boundary.schema'; +import type { WindowPlan } from './dayWindow'; +import type { AutomaticNight } from './dayWindow'; +import type { SpillMark } from './nightSpill'; +import type { Schedule, ScheduleWarning, DayWarning, DryPoint } from './roadtripModel'; + +export interface RoadtripStop { + automaticNight?: AutomaticNight; + assignmentId: number; + + ownerDayId: number; + ownerIndex: number; + placeId: number; + name: string; + lat: number; + lng: number; + + time: string | null; + + dwellMinutes: number | null; + endDay?: boolean; + + legMode: string | null; + incomingLegMode: string | null; + + stopType: string | null; + + fillPercent?: number | null; + + offRoadMeters?: number | null; +} + +export interface RoutedLeg { + seg: RouteSegment; + line: [number, number][]; + + snapped?: { from: SnappedPoint | null; to: SnappedPoint | null }; + + vias: RouteVia[]; +} + +export interface SnappedPoint { + lat: number; + lng: number; + + offRoadMeters: number; +} + +export interface RouteSegment { + mid: [number, number]; + from: [number, number]; + to: [number, number]; + distance: number; + duration: number; + walkingText: string; + drivingText: string; + distanceText: string; + durationText?: string; + + noteText?: string; + + mode?: string; +} + +export interface RouteVia { + hoverCard?: boolean; + nightPause?: { + day: number; + atPlace: boolean; + position?: number; + manual?: boolean; + minPosition?: number; + maxPosition?: number; + }; + lat: number; + lng: number; + label?: string; + tone: 'default' | 'success' | 'warn' | 'danger'; + dwellSeconds?: number; +} + +export interface SnappedWaypoint { + asked: [number, number]; + + at: [number, number]; + + meters: number; +} + +export type DistanceUnit = 'metric' | 'imperial'; +export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'; +export interface RoadtripDay { + automaticSchedule?: boolean; + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + stops: RoadtripStop[]; + + legs: (RouteSegment | undefined)[]; + + schedule: Schedule; + + legVias: RouteVia[][]; + + avoidMissed?: RouteAvoidClass[]; + + dryPoints?: (DryPoint & { lat: number; lng: number })[]; + + drivingGeometry?: [number, number][]; + + geometry: [number, number][]; + distance: number; + duration: number; + + driveWarnings: ScheduleWarning[]; + + dayWarning: DayWarning | null; + + spills?: SpillMark[]; +} + +export type PlanDay = Pick; + +export interface AccessSpur { + line: [[number, number], [number, number]]; + meters: number; + + stopKey: string; +} + +export interface QuietDay { + dayId: number; + dayNumber: number; + date: string | null; + title: string | null; + + stops: RoadtripStop[]; +} + +export interface RoadtripRoutes { + boundaryPath?: DayBoundaryLeg[]; + validateBoundaries?: (boundaries: RoadtripDayBoundary[]) => WindowPlan['issue']; + dayWindowIssue?: 'incomplete' | 'conflict' | 'tooLong' | 'legTooLong' | null; + days: RoadtripDay[]; + + quietDays: QuietDay[]; + + lines: [number, number][][]; + + lineDays: number[]; + + accessLines: AccessSpur[]; + + vias: RouteVia[]; + + segments: RouteSegment[]; + totalDistance: number; + totalDuration: number; + totalStops: number; + + loading: boolean; +} + +export interface DayBoundaryLeg { + from: RoadtripStop; + to: RoadtripStop; + position: number; + line: [number, number][]; +} diff --git a/shared/src/roadtrip/planning.schema.spec.ts b/shared/src/roadtrip/planning.schema.spec.ts new file mode 100644 index 000000000..804cc28dc --- /dev/null +++ b/shared/src/roadtrip/planning.schema.spec.ts @@ -0,0 +1,47 @@ +import { roadtripPlanRequestSchema, roadtripCorridorRequestSchema, roadtripGpxImportSchema } from './planning.schema'; +import { roadtripPreferencesUpdateSchema } from './preferences.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Roadtrip MCP contracts', () => { + it('defaults to a compact read-only calculation', () => { + expect(roadtripPlanRequestSchema.parse({ tripId: 3 })).toEqual({ tripId: 3, includeGeometry: false }); + expect(roadtripPlanRequestSchema.safeParse({ tripId: -1 }).success).toBe(false); + }); + it.each([ + { roadtrip_day_start: '24:00' }, + { roadtrip_fill_percent: 101 }, + { routing_base_url: 'http://localhost' }, + {}, + { roadtrip_range_km: -1 }, + { roadtrip_avoid: 'unknown' }, + ])('rejects invalid driving preferences %j', (settings) => { + expect(roadtripPreferencesUpdateSchema.safeParse(settings).success).toBe(false); + }); + it('allows clearing settings and preserves manual controls', () => { + expect( + roadtripPreferencesUpdateSchema.parse({ + roadtrip_day_start: '', + roadtrip_range_km: 0, + roadtrip_day_end_mode: 'stop', + }), + ).toMatchObject({ roadtrip_day_start: '' }); + }); + it('bounds corridor queries', () => { + expect(roadtripCorridorRequestSchema.parse({ tripId: 1, dayNumber: 1, category: 'charging' })).toMatchObject({ + offset: 0, + widthKm: 5, + }); + expect( + roadtripCorridorRequestSchema.safeParse({ tripId: 1, dayNumber: 1, category: 'charging', widthKm: 500 }).success, + ).toBe(false); + }); + it('bounds GPX input and defaults all supported import types', () => { + expect(roadtripGpxImportSchema.parse({ tripId: 1, gpx: '' })).toMatchObject({ + importTracks: true, + importRoutes: true, + importWaypoints: true, + }); + expect(roadtripGpxImportSchema.safeParse({ tripId: 1, gpx: 'x'.repeat(1000001) }).success).toBe(false); + }); +}); diff --git a/shared/src/roadtrip/planning.schema.ts b/shared/src/roadtrip/planning.schema.ts new file mode 100644 index 000000000..aab29f0a8 --- /dev/null +++ b/shared/src/roadtrip/planning.schema.ts @@ -0,0 +1,44 @@ +import { idSchema } from '../common/primitives.schema'; +import { roadtripPreferencesSchema } from './preferences.schema'; + +import { z } from 'zod'; + +export const roadtripPlanRequestSchema = z.object({ + tripId: idSchema, + includeGeometry: z.boolean().optional().default(false), + settings: roadtripPreferencesSchema.optional().describe('Preview these personal preferences without saving them'), +}); +export const roadtripCorridorRequestSchema = z.object({ + tripId: idSchema, + dayNumber: z.number().int().positive().max(366), + category: z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights', 'hotel']), + widthKm: z.number().positive().max(10).default(5), + offset: z.number().int().min(0).max(1000).default(0), + name: z.string().max(100).optional(), + socket: z.string().max(50).optional(), + minKw: z.number().nonnegative().max(10000).optional(), + fromKm: z + .number() + .nonnegative() + .max(40000) + .optional() + .describe('Only matches at or after this distance along the day route'), + toKm: z + .number() + .nonnegative() + .max(40000) + .optional() + .describe('Only matches at or before this distance along the day route'), +}); +export type RoadtripPlanRequest = z.infer; +export type RoadtripCorridorRequest = z.infer; + +export const roadtripGpxImportSchema = z.object({ + tripId: idSchema, + gpx: z.string().min(1).max(1000000).describe('GPX XML text, up to one million characters'), + name: z.string().min(1).max(200).default('Imported track'), + importWaypoints: z.boolean().default(true), + importRoutes: z.boolean().default(true), + importTracks: z.boolean().default(true), +}); +export type RoadtripGpxImport = z.infer; diff --git a/shared/src/roadtrip/planning.ts b/shared/src/roadtrip/planning.ts new file mode 100644 index 000000000..4d21da7f5 --- /dev/null +++ b/shared/src/roadtrip/planning.ts @@ -0,0 +1,8 @@ +export * from './planning-types'; +export * from './roadtripModel'; +export * from './corridor'; +export * from './nightSpill'; +export * from './dayWindow'; +export * from './vehicleRange'; +export * from './assemble'; +export * from './routeRun'; diff --git a/shared/src/roadtrip/preferences.schema.ts b/shared/src/roadtrip/preferences.schema.ts new file mode 100644 index 000000000..ccb28c535 --- /dev/null +++ b/shared/src/roadtrip/preferences.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +const clock = z.union([z.literal(''), z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/)]); +const amount = z.number().finite().nonnegative(); + +export const roadtripPreferencesSchema = z + .object({ + roadtrip_leg_minutes: amount.max(1440).optional(), + roadtrip_day_minutes: amount.max(1440).optional(), + roadtrip_day_start: clock.optional(), + roadtrip_day_end: clock.optional(), + roadtrip_day_end_mode: z.enum(['route', 'stop']).optional(), + roadtrip_range_km: amount.max(40000).optional(), + roadtrip_vehicle: z.enum(['', 'combustion', 'electric']).optional(), + roadtrip_fill_percent: amount.max(100).optional(), + roadtrip_tank_litres: amount.max(10000).optional(), + roadtrip_litres_per_100: amount.max(1000).optional(), + roadtrip_battery_kwh: amount.max(10000).optional(), + roadtrip_kwh_per_100: amount.max(1000).optional(), + roadtrip_battery_degradation: amount.max(100).optional(), + roadtrip_connect_days: z.boolean().optional(), + roadtrip_day_colors: z.boolean().optional(), + roadtrip_avoid: z + .string() + .max(32) + .refine( + (value) => value === '' || value.split(',').every((part) => ['toll', 'motorway', 'ferry'].includes(part)), + 'Expected a comma-separated selection of toll, motorway, ferry', + ) + .optional(), + }) + .strict(); + +export const roadtripPreferencesUpdateSchema = roadtripPreferencesSchema.refine( + (value) => Object.keys(value).length > 0, + 'Provide at least one driving preference', +); +export type RoadtripPreferences = z.infer; +export const ROADTRIP_PREFERENCE_KEYS = Object.keys(roadtripPreferencesSchema.shape) as (keyof RoadtripPreferences)[]; diff --git a/shared/src/roadtrip/roadtrip.schema.ts b/shared/src/roadtrip/roadtrip.schema.ts index 8efe3b05e..70ac40984 100644 --- a/shared/src/roadtrip/roadtrip.schema.ts +++ b/shared/src/roadtrip/roadtrip.schema.ts @@ -74,10 +74,14 @@ export type RoadtripViaUpdateRequest = z.infer; @@ -95,11 +99,15 @@ export type RoadtripViaReanchorRequest = z.infer; diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/shared/src/roadtrip/roadtripModel.spec.ts similarity index 71% rename from client/src/components/Roadtrip/roadtripModel.test.ts rename to shared/src/roadtrip/roadtripModel.spec.ts index 1dc6b7a30..b207ca236 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/shared/src/roadtrip/roadtripModel.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { computeSchedule, formatClock, @@ -12,68 +11,70 @@ import { splitIntoRuns, sumLegSeconds, refuelStopTypeFor, -} from './roadtripModel' +} from './roadtripModel'; + +import { describe, it, expect } from 'vitest'; describe('formatDurationShort', () => { it('prints minutes below an hour', () => { - expect(formatDurationShort(45 * 60)).toBe('45 min') - expect(formatDurationShort(0)).toBe('0 min') - }) + expect(formatDurationShort(45 * 60)).toBe('45 min'); + expect(formatDurationShort(0)).toBe('0 min'); + }); it('drops the zero minutes on a whole hour', () => { - expect(formatDurationShort(3600)).toBe('1 h') - expect(formatDurationShort(2 * 3600)).toBe('2 h') - }) + expect(formatDurationShort(3600)).toBe('1 h'); + expect(formatDurationShort(2 * 3600)).toBe('2 h'); + }); it('prints hours and minutes together', () => { - expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min') - }) + expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min'); + }); it('carries instead of printing sixty minutes', () => { // 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h. - expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h') - }) + expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h'); + }); it('treats nonsense as nothing rather than throwing', () => { - expect(formatDurationShort(Number.NaN)).toBe('0 min') - expect(formatDurationShort(-90)).toBe('0 min') - }) -}) + expect(formatDurationShort(Number.NaN)).toBe('0 min'); + expect(formatDurationShort(-90)).toBe('0 min'); + }); +}); describe('parseClock', () => { it('reads wall-clock times', () => { - expect(parseClock('09:45')).toBe(9 * 60 + 45) - expect(parseClock('9:05')).toBe(9 * 60 + 5) - expect(parseClock('00:00')).toBe(0) - }) + expect(parseClock('09:45')).toBe(9 * 60 + 45); + expect(parseClock('9:05')).toBe(9 * 60 + 5); + expect(parseClock('00:00')).toBe(0); + }); it('ignores anything trailing, as stored times sometimes carry seconds', () => { - expect(parseClock('14:30:00')).toBe(14 * 60 + 30) - }) + expect(parseClock('14:30:00')).toBe(14 * 60 + 30); + }); it('rejects what is not a time', () => { - expect(parseClock(null)).toBeNull() - expect(parseClock('')).toBeNull() - expect(parseClock('later')).toBeNull() - expect(parseClock('25:00')).toBeNull() - expect(parseClock('12:75')).toBeNull() - }) -}) + expect(parseClock(null)).toBeNull(); + expect(parseClock('')).toBeNull(); + expect(parseClock('later')).toBeNull(); + expect(parseClock('25:00')).toBeNull(); + expect(parseClock('12:75')).toBeNull(); + }); +}); describe('formatClock', () => { it('pads both halves', () => { - expect(formatClock(9 * 60 + 5)).toBe('09:05') - expect(formatClock(0)).toBe('00:00') - }) + expect(formatClock(9 * 60 + 5)).toBe('09:05'); + expect(formatClock(0)).toBe('00:00'); + }); it('wraps past midnight instead of printing a 25th hour', () => { - expect(formatClock(25 * 60)).toBe('01:00') - expect(formatClock(-30)).toBe('23:30') - }) -}) + expect(formatClock(25 * 60)).toBe('01:00'); + expect(formatClock(-30)).toBe('23:30'); + }); +}); describe('computeSchedule', () => { - const hours = (h: number): number => h * 3600 + const hours = (h: number): number => h * 3600; it('walks arrival and departure forward from the first pinned time', () => { // The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10. @@ -84,11 +85,11 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 45 }, ], [hours(1.75), hours(1) + 10 * 60], - ) - expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true }) - expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false }) - expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }) - }) + ); + expect(entries[0]!).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true }); + expect(entries[1]!).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false }); + expect(entries[2]!).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false }); + }); 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 @@ -100,10 +101,10 @@ describe('computeSchedule', () => { { anchor: '10:00', dwellMinutes: 30 }, ], [hours(1)], - ) - expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '09:00', anchored: false }) - expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true }) - }) + ); + 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( @@ -113,12 +114,12 @@ describe('computeSchedule', () => { { anchor: '10:00', dwellMinutes: 30 }, ], [undefined, hours(1)], - ) - expect(entries[0]).toMatchObject({ arrival: null, departure: null }) + ); + 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 }) - }) + 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 @@ -130,11 +131,11 @@ describe('computeSchedule', () => { { 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' }) - }) + ); + 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 @@ -146,10 +147,10 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [hours(1), hours(1)], - ) - expect(entries[1].arrival).toBe('11:00') - expect(entries[2].arrival).toBe('13:00') - }) + ); + expect(entries[1]!.arrival).toBe('11:00'); + expect(entries[2]!.arrival).toBe('13:00'); + }); it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => { // Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be @@ -164,13 +165,13 @@ describe('computeSchedule', () => { { anchor: '02:00', dwellMinutes: 0 }, ], [hours(6)], - ) + ); - expect(warnings.filter(w => w.code === 'late')).toEqual([]) - expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true }) + expect(warnings.filter((w) => w.code === 'late')).toEqual([]); + expect(entries[1]!).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true }); // The day still turned over, so the rail keeps its carry badge. - expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true) - }) + expect(warnings.some((w) => w.code === 'overnight' && w.index === 1)).toBe(true); + }); it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => { // The other half: picking the nearest occurrence must not swallow a genuine @@ -182,9 +183,9 @@ describe('computeSchedule', () => { { anchor: '01:30', dwellMinutes: 0 }, ], [hours(5)], - ) - expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 }) - }) + ); + expect(warnings.find((w) => w.code === 'late')!).toMatchObject({ index: 1, minutes: 30 }); + }); it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => { // Nothing to be late against, but the day carried so far still applies — @@ -196,9 +197,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(6), undefined], - ) - expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 }) - }) + ); + expect(entries[2]!).toMatchObject({ arrival: '09:00', dayOffset: 1 }); + }); it('reports a stop the drive cannot reach in time', () => { const { warnings } = computeSchedule( @@ -207,9 +208,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(3)], - ) - expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }]) - }) + ); + expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }]); + }); it('does not cry about a minute of rounding', () => { const { warnings } = computeSchedule( @@ -218,9 +219,9 @@ describe('computeSchedule', () => { { anchor: '09:00', dwellMinutes: 0 }, ], [hours(1) + 30], - ) - expect(warnings).toEqual([]) - }) + ); + expect(warnings).toEqual([]); + }); it('breaks the chain at a leg that never routed', () => { const { entries } = computeSchedule( @@ -230,11 +231,11 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [undefined, hours(1)], - ) - expect(entries[0].arrival).toBe('08:00') - expect(entries[1].arrival).toBeNull() - expect(entries[2].arrival).toBeNull() - }) + ); + expect(entries[0]!.arrival).toBe('08:00'); + expect(entries[1]!.arrival).toBeNull(); + expect(entries[2]!.arrival).toBeNull(); + }); it('flags the day rolling past midnight and keeps counting', () => { const { entries, warnings } = computeSchedule( @@ -243,15 +244,15 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: 0 }, ], [hours(4)], - ) - expect(entries[1].arrival).toBe('02:00') - expect(entries[1].dayOffset).toBe(1) - expect(warnings).toEqual([{ index: 1, code: 'overnight' }]) - }) + ); + expect(entries[1]!.arrival).toBe('02:00'); + expect(entries[1]!.dayOffset).toBe(1); + expect(warnings).toEqual([{ index: 1, code: 'overnight' }]); + }); it('handles an empty chain', () => { - expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] }) - }) + expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] }); + }); it('treats a missing dwell as no time spent', () => { const { entries } = computeSchedule( @@ -260,109 +261,109 @@ describe('computeSchedule', () => { { anchor: null, dwellMinutes: null }, ], [hours(1)], - ) - expect(entries[0].departure).toBe('08:00') - expect(entries[1].arrival).toBe('09:00') - }) -}) + ); + expect(entries[0]!.departure).toBe('08:00'); + expect(entries[1]!.arrival).toBe('09:00'); + }); +}); describe('splitIntoRuns', () => { - const drive = (): string => 'driving' + const drive = (): string => 'driving'; it('asks for a whole day travelled one way in a single run', () => { - const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive) - expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }]) - }) + const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive); + expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }]); + }); it('splits where the travel mode changes, and repeats the stop on both sides', () => { // Drive to b, walk to c, drive on to d: the walk is its own request, and b and c // each belong to two runs because they are the ends of neighbouring legs. - const modes = ['driving', 'walking', 'driving'] - const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)]) + const modes = ['driving', 'walking', 'driving']; + const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)]!); expect(runs).toEqual([ { stops: ['a', 'b'], mode: 'driving' }, { stops: ['b', 'c'], mode: 'walking' }, { stops: ['c', 'd'], mode: 'driving' }, - ]) - }) + ]); + }); it('keeps consecutive legs of the same mode together across a change and back', () => { - const modes = ['driving', 'driving', 'walking', 'walking'] - const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)]) + const modes = ['driving', 'driving', 'walking', 'walking']; + const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)]!); expect(runs).toEqual([ { stops: ['a', 'b', 'c'], mode: 'driving' }, { stops: ['c', 'd', 'e'], mode: 'walking' }, - ]) - }) + ]); + }); it('has nothing to ask for when there is no leg', () => { - expect(splitIntoRuns([], drive)).toEqual([]) - expect(splitIntoRuns(['a'], drive)).toEqual([]) - }) -}) + expect(splitIntoRuns([], drive)).toEqual([]); + expect(splitIntoRuns(['a'], drive)).toEqual([]); + }); +}); describe('sumLegSeconds', () => { it('adds only the legs that routed', () => { - expect(sumLegSeconds([600, undefined, 1200])).toBe(1800) - expect(sumLegSeconds([])).toBe(0) - }) -}) + expect(sumLegSeconds([600, undefined, 1200])).toBe(1800); + expect(sumLegSeconds([])).toBe(0); + }); +}); describe('insertIndexForAlong', () => { // Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven. - const stops = [0, 50, 290] + const stops = [0, 50, 290]; it('puts a hit between the two stops it falls between', () => { - expect(insertIndexForAlong(stops, 120)).toBe(2) - expect(insertIndexForAlong(stops, 20)).toBe(1) - }) + expect(insertIndexForAlong(stops, 120)).toBe(2); + expect(insertIndexForAlong(stops, 20)).toBe(1); + }); it('never lands before the stop the day starts from', () => { - expect(insertIndexForAlong(stops, 0)).toBe(1) + expect(insertIndexForAlong(stops, 0)).toBe(1); // A corridor is wider than the road, so a hit can project just behind the start. - expect(insertIndexForAlong(stops, -3)).toBe(1) - }) + expect(insertIndexForAlong(stops, -3)).toBe(1); + }); it('never lands after the stop the day ends at', () => { - expect(insertIndexForAlong(stops, 290)).toBe(2) - expect(insertIndexForAlong(stops, 400)).toBe(2) - }) + expect(insertIndexForAlong(stops, 290)).toBe(2); + expect(insertIndexForAlong(stops, 400)).toBe(2); + }); it('puts a hit exactly on a stop after it, not before', () => { - expect(insertIndexForAlong(stops, 50)).toBe(2) - }) + expect(insertIndexForAlong(stops, 50)).toBe(2); + }); it('has nowhere to insert on a day that is not a drive', () => { - expect(insertIndexForAlong([], 10)).toBe(0) - expect(insertIndexForAlong([0], 10)).toBe(1) - }) -}) + expect(insertIndexForAlong([], 10)).toBe(0); + expect(insertIndexForAlong([0], 10)).toBe(1); + }); +}); describe('legIndexForAlong', () => { // Against the legs' own lengths rather than by projecting the stops as well: leg // distances come from the router and are exact, while two stops close together project // onto each other's stretch and would put a charging halt the wrong side of a town. - const legs = [10_000, 25_000, 40_000] + const legs = [10_000, 25_000, 40_000]; it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => { - expect(legIndexForAlong(legs, 0)).toBe(0) - expect(legIndexForAlong(legs, 9_999)).toBe(0) - expect(legIndexForAlong(legs, 10_000)).toBe(1) - expect(legIndexForAlong(legs, 24_999)).toBe(1) - expect(legIndexForAlong(legs, 25_000)).toBe(2) - }) + expect(legIndexForAlong(legs, 0)).toBe(0); + expect(legIndexForAlong(legs, 9_999)).toBe(0); + expect(legIndexForAlong(legs, 10_000)).toBe(1); + expect(legIndexForAlong(legs, 24_999)).toBe(1); + expect(legIndexForAlong(legs, 25_000)).toBe(2); + }); it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => { // A rounding edge, not a missing leg: the projection and the router measure the same // line two different ways and disagree by metres at the very end. - expect(legIndexForAlong(legs, 40_000)).toBe(2) - expect(legIndexForAlong(legs, 99_999)).toBe(2) - }) + expect(legIndexForAlong(legs, 40_000)).toBe(2); + expect(legIndexForAlong(legs, 99_999)).toBe(2); + }); it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => { - expect(legIndexForAlong([], 100)).toBe(-1) - }) -}) + expect(legIndexForAlong([], 100)).toBe(-1); + }); +}); describe('deriveDriveWarnings', () => { // Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on @@ -373,14 +374,14 @@ describe('deriveDriveWarnings', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) + ); // 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this // trip fills up. expect(out.warnings).toEqual([ { index: 2, code: 'range', sinceKm: 125 }, { index: 3, code: 'range', sinceKm: 370 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => { // Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly @@ -391,19 +392,19 @@ describe('deriveDriveWarnings', () => { [false, false, true, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) - expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }]) + ); + expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }]); // 245 and not 370: HEM filled the tank, so Berlin counts from there. - }) + }); - const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null } - const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 }) + const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }; + const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 }); it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => { - const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0) - expect(out.warnings).toEqual([]) - expect(out.day).toBeNull() - }) + const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0); + expect(out.warnings).toEqual([]); + expect(out.day).toBeNull(); + }); it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => { // On arrival, not on departure. Both findings are running totals that only become @@ -414,9 +415,9 @@ describe('deriveDriveWarnings', () => { [false, false, false], { ...noLimits, legMinutes: 180 }, 0, - ) - expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }]) - }) + ); + expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }]); + }); it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => { const out = deriveDriveWarnings( @@ -424,9 +425,9 @@ describe('deriveDriveWarnings', () => { [false, false, false], { ...noLimits, dayMinutes: 360 }, 0, - ) - expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 }) - }) + ); + expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 }); + }); it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => { // A warning is not a fill-up. Zeroing the budget at one made every figure after the @@ -437,13 +438,13 @@ describe('deriveDriveWarnings', () => { [false, false, false, false, false], { ...noLimits, rangeKm: 600 }, 0, - ) + ); expect(out.warnings).toEqual([ { index: 2, code: 'range', sinceKm: 700 }, { index: 3, code: 'range', sinceKm: 1000 }, { index: 4, code: 'range', sinceKm: 1400 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => { // The same drive with a charger at stop 2. Nothing is flagged there, because reaching @@ -454,58 +455,48 @@ describe('deriveDriveWarnings', () => { [false, false, true, false, false], { ...noLimits, rangeKm: 600 }, 0, - ) - expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }]) - }) + ); + expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }]); + }); it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => { - const stops = [false, true, false] - const out = deriveDriveWarnings( - [leg(60, 500), leg(60, 500)], - stops, - { ...noLimits, rangeKm: 600 }, - 0, - ) + const stops = [false, true, false]; + const out = deriveDriveWarnings([leg(60, 500), leg(60, 500)], stops, { ...noLimits, rangeKm: 600 }, 0); // The tank is filled at stop 1, so the second 500 km starts from zero and neither leg // trips the limit. Without the refuel it would be 1000 km on a 600 km range. - expect(out.warnings).toEqual([]) - }) + expect(out.warnings).toEqual([]); + }); it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => { - expect(refuelsRange('fuel')).toBe(true) - expect(refuelsRange('charging')).toBe(true) + expect(refuelsRange('fuel')).toBe(true); + expect(refuelsRange('charging')).toBe(true); // A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a // charger in its car park. - expect(refuelsRange('restaurant')).toBe(false) - expect(refuelsRange('rest_area')).toBe(false) - expect(refuelsRange('campsite')).toBe(false) - expect(refuelsRange(null)).toBe(false) - }) + expect(refuelsRange('restaurant')).toBe(false); + expect(refuelsRange('rest_area')).toBe(false); + expect(refuelsRange('campsite')).toBe(false); + expect(refuelsRange(null)).toBe(false); + }); it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => { - const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0) - expect(first.carryKm).toBe(400) + const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0); + expect(first.carryKm).toBe(400); - const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm) - expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }]) - }) + const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm); + expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }]); + }); it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => { - const out = deriveDriveWarnings( - [undefined, leg(60, 500)], - [false, false, false], - { ...noLimits, rangeKm: 100 }, - 0, - ) - expect(out.warnings).toEqual([]) - expect(out.carryKm).toBeNull() - }) + const out = deriveDriveWarnings([undefined, leg(60, 500)], [false, false, false], { ...noLimits, rangeKm: 100 }, 0); + expect(out.warnings).toEqual([]); + expect(out.carryKm).toBeNull(); + }); it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => { - const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0) - expect(out.carryKm).toBe(0) - }) -}) + const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0); + expect(out.carryKm).toBe(0); + }); +}); describe('sectionAnchors', () => { it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => { @@ -517,20 +508,18 @@ describe('sectionAnchors', () => { { kind: 'stop', index: 1, alongKm: 100 }, { kind: 'leg', index: 1, alongKm: 175 }, { kind: 'stop', index: 2, alongKm: 250 }, - ]) - }) + ]); + }); it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => { - expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }]) - expect(sectionAnchors([])).toEqual([]) - }) -}) + expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }]); + expect(sectionAnchors([])).toEqual([]); + }); +}); describe('deriveDriveWarnings — what counts as driving', () => { - const drive = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }) - const onFoot = (minutes: number, km: number) => - ({ duration: minutes * 60, distance: km * 1000, mode: 'walking' }) + const drive = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'driving' }); + const onFoot = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000, mode: 'walking' }); it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => { // A day may legitimately mix modes — splitIntoRuns exists for exactly that — @@ -541,9 +530,9 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false], { legMinutes: 90, dayMinutes: null, rangeKm: null }, 0, - ) - expect(out.warnings).toEqual([]) - }) + ); + expect(out.warnings).toEqual([]); + }); it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => { // Nor does walking fill it: the budget is left exactly as the drive left it. @@ -552,11 +541,11 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 100 }, 0, - ) + ); // 80 then 110 — the 60 walked kilometres never entered the budget. - expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }]) - expect(out.carryKm).toBe(110) - }) + expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }]); + expect(out.carryKm).toBe(110); + }); it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => { const out = deriveDriveWarnings( @@ -564,9 +553,9 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false, false], { legMinutes: null, dayMinutes: 90, rangeKm: null }, 0, - ) - expect(out.day).toBeNull() - }) + ); + expect(out.day).toBeNull(); + }); it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => { // Every leg was a drive before the field existed, so an absent mode counts. @@ -576,16 +565,14 @@ describe('deriveDriveWarnings — what counts as driving', () => { [false, false], { legMinutes: 90, dayMinutes: null, rangeKm: null }, 0, - ) - expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }]) - }) -}) + ); + 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' }) + 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; @@ -596,12 +583,10 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [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 }, - ]) - }) + ); + 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 @@ -612,11 +597,11 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [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 }) - }) + ); + 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( @@ -624,11 +609,11 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [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) - }) + 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 @@ -639,11 +624,9 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(out.emptyAt).toEqual([ - { legIndex: 2, intoLegKm: 100, drivenMeters: 300000, sinceKm: 300 }, - ]) - }) + ); + 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( @@ -651,12 +634,10 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [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 }, - ]) - }) + 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( @@ -664,17 +645,17 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false], { legMinutes: null, dayMinutes: null, rangeKm: null }, 0, - ) - expect(noLimit.emptyAt).toEqual([]) + ); + expect(noLimit.emptyAt).toEqual([]); const withinRange = deriveDriveWarnings( [drive(60, 100)], [false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(withinRange.emptyAt).toEqual([]) - }) + ); + 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. @@ -683,53 +664,52 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => { [false, false, false, false], { legMinutes: null, dayMinutes: null, rangeKm: 300 }, 0, - ) - expect(out.emptyAt).toEqual([]) - expect(out.carryKm).toBeNull() - }) -}) + ); + 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) - }) + 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) - }) + 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) - }) + 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) + 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']) - }) -}) + 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' }) + 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 @@ -739,24 +719,24 @@ describe('deriveDriveWarnings — filling only part way', () => { [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 }) - }) + 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 } + 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) + 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([]) + 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( @@ -764,10 +744,10 @@ describe('deriveDriveWarnings — filling only part way', () => { [true, false], { legMinutes: null, dayMinutes: null, rangeKm: null, fillPercent: 50 }, 0, - ) - expect(out.warnings).toEqual([]) - expect(out.emptyAt).toEqual([]) - }) + ); + 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 @@ -778,12 +758,12 @@ describe('deriveDriveWarnings — filling only part way', () => { { 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 }) - }) + 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 @@ -794,9 +774,9 @@ describe('deriveDriveWarnings — filling only part way', () => { { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, [null, null, null], - ) - expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 }) - }) + ); + 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 @@ -807,7 +787,7 @@ describe('deriveDriveWarnings — filling only part way', () => { { legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 }, 0, [null, 100, null], - ) - expect(out.emptyAt).toEqual([]) - }) -}) + ); + expect(out.emptyAt).toEqual([]); + }); +}); diff --git a/shared/src/roadtrip/roadtripModel.ts b/shared/src/roadtrip/roadtripModel.ts new file mode 100644 index 000000000..e4cd13a25 --- /dev/null +++ b/shared/src/roadtrip/roadtripModel.ts @@ -0,0 +1,448 @@ +import type { RouteAvoidClass } from './planning-types'; + +export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const; + +export const SERVICE_COLORS: Record = { + fuel: '#E8590C', + charging: '#CA8A04', + rest_area: '#3B82F6', + campsite: '#16A34A', + restaurant: '#EF4444', + sights: '#EC4899', +}; + +export function serviceColor(stopType: string | null | undefined): string { + return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'; +} + +export function isServiceStopType(stopType: string | null | undefined): boolean { + return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType); +} + +export function formatDurationShort(seconds: number): string { + const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0; + const h = Math.floor(safe / 3600); + const m = Math.round((safe % 3600) / 60); + + if (m === 60) return `${h + 1} h`; + if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`; + return `${m} min`; +} + +export function parseClock(value: string | null | undefined): number | null { + if (!value) return null; + const m = /^(\d{1,2}):(\d{2})/.exec(value.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + return h * 60 + min; +} + +export function formatClock(minutes: number): string { + const day = 24 * 60; + const wrapped = ((minutes % day) + day) % day; + const h = Math.floor(wrapped / 60); + const m = Math.round(wrapped % 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +export interface ScheduleStop { + anchor: string | null; + + dwellMinutes: number | null; +} + +export interface ScheduleWarning { + index: number; + code: 'late' | 'overnight' | 'leg' | 'range'; + + minutes?: number; + + overMinutes?: number; + + sinceKm?: number; +} + +export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const; + +export type VehicleKind = 'combustion' | 'electric'; + +export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] { + if (vehicle === 'combustion') return ['fuel']; + if (vehicle === 'electric') return ['charging']; + return REFUELLING_STOP_TYPES; +} + +export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean { + return refuelStopTypeFor(vehicle).includes(stopType ?? ''); +} + +export interface DriveLimits { + fillPercent?: number | null; + + legMinutes: number | null; + + dayMinutes: number | null; + + rangeKm: number | null; +} + +export interface DayWarning { + code: 'dayDriving'; + + minutes: number; + limitMinutes: number; +} + +export interface ScheduleEntry { + arrival: string | null; + departure: string | null; + + anchored: boolean; + + dayOffset: number; +} + +export interface Schedule { + entries: ScheduleEntry[]; + warnings: ScheduleWarning[]; +} + +const DAY_MINUTES = 24 * 60; + +function resolveArrival( + anchor: number | null, + cursor: number | null, + dayOffset: number, +): { arrival: number | null; lateBy: number | null } { + if (anchor === null) return { arrival: cursor, lateBy: null }; + + if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }; + + const k = Math.round((cursor - anchor) / DAY_MINUTES); + const anchorAt = anchor + k * DAY_MINUTES; + return { + arrival: anchorAt, + lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null, + }; +} + +export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule { + const warnings: ScheduleWarning[] = []; + + const arrivals: (number | null)[] = new Array(stops.length).fill(null); + const anchored: boolean[] = new Array(stops.length).fill(false); + + let cursor: number | null = null; + let dayOffset = 0; + + for (let i = 0; i < stops.length; i++) { + const stop = stops[i]!; + const anchor = parseClock(stop.anchor); + const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset); + if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy }); + + 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); + } + + 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); + } + + 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 arrival = raw + shift; + const offset = Math.floor(arrival / DAY_MINUTES); + + if (offset > lastOffset) warnings.push({ index: i, code: 'overnight' }); + lastOffset = offset; + entries.push({ + arrival: formatClock(arrival), + departure: formatClock(arrival + (stops[i]!.dwellMinutes ?? 0)), + anchored: anchored[i]!, + dayOffset: offset, + }); + } + + return { entries, warnings }; +} + +export function splitIntoRuns(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] { + const runs: { stops: T[]; mode: string }[] = []; + let current: T[] = []; + let currentMode: string | null = null; + + for (let i = 0; i < stops.length - 1; i++) { + const mode = modeOfLeg(stops[i]!, stops[i + 1]!); + if (currentMode === null) { + current = [stops[i]!, stops[i + 1]!]; + currentMode = mode; + } else if (mode === currentMode) { + current.push(stops[i + 1]!); + } else { + runs.push({ stops: current, mode: currentMode }); + current = [stops[i]!, stops[i + 1]!]; + currentMode = mode; + } + } + if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode }); + return runs; +} + +export function sumLegSeconds(legSeconds: (number | undefined)[]): number { + return legSeconds.reduce((sum, s) => sum + (s ?? 0), 0); +} + +export interface DryPoint { + legIndex: number; + + intoLegKm: number; + + drivenMeters: number; + + sinceKm: number; +} + +export function deriveDriveWarnings( + legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[], + + refuelsAt: boolean[], + limits: DriveLimits, + + carryKm: number | null, + + 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; + + let drivenMeters = 0; + + const usedAfterFilling = (i: number): number => { + const percent = fillAt[i] ?? limits.fillPercent; + return limits.rangeKm && percent && percent > 0 && percent < 100 ? limits.rangeKm * (1 - percent / 100) : 0; + }; + + for (let i = 0; i < legs.length; i++) { + if (refuelsAt[i]!) budget = usedAfterFilling(i); + const leg = legs[i]; + + const at = i + 1; + + if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue; + const seconds = leg?.duration; + if (typeof seconds === 'number') { + totalSeconds += seconds; + if (limits.legMinutes && seconds / 60 > limits.legMinutes) { + warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) }); + } + } + const metres = leg?.distance; + if (typeof metres !== 'number') { + budget = null; + } else if (budget !== null) { + const before = budget; + budget += metres / 1000; + + 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), + }); + } + + if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]!) { + warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) }); + } + drivenMeters += metres; + } + } + + 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, emptyAt }; +} + +export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number { + if (!legEndMeters.length) return -1; + for (let i = 0; i < legEndMeters.length; i++) { + if (alongMeters < legEndMeters[i]!) return i; + } + return legEndMeters.length - 1; +} + +export interface SectionAnchor { + kind: 'stop' | 'leg'; + + index: number; + alongKm: number; +} + +export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] { + const out: SectionAnchor[] = []; + for (let i = 0; i < stopsAlongKm.length; i++) { + out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i]! }); + if (i + 1 < stopsAlongKm.length) { + out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i]! + stopsAlongKm[i + 1]!) / 2 }); + } + } + return out; +} + +export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number { + if (stopsAlongKm.length < 2) return stopsAlongKm.length; + let i = 0; + while (i < stopsAlongKm.length && stopsAlongKm[i]! <= hitAlongKm) i++; + return Math.min(Math.max(i, 1), stopsAlongKm.length - 1); +} + +export interface AnchoredVia { + id: number; + after_order_index: number; + lat: number; + lng: number; +} + +export interface ReanchoredVia { + id: number; + after_order_index: number; +} + +export interface Reanchoring { + vias: ReanchoredVia[]; + remove: number[]; +} + +const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }; + +function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring { + const moved: ReanchoredVia[] = []; + const remove: number[] = []; + for (const via of vias) { + const next = at(via.after_order_index); + if (next === null) remove.push(via.id); + else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }); + } + return { vias: moved, remove }; +} + +export function reanchorAfterInsert( + vias: AnchoredVia[], + position: number, + liesBeforeInsert: (via: AnchoredVia) => boolean, +): Reanchoring { + if (!vias.length) return EMPTY_REANCHORING; + const split = position - 1; + const moved: ReanchoredVia[] = []; + for (const via of vias) { + const i = via.after_order_index; + let next = i; + if (i === split) next = liesBeforeInsert(via) ? i : i + 1; + else if (i >= position) next = i + 1; + if (next !== i) moved.push({ id: via.id, after_order_index: next }); + } + return { vias: moved, remove: [] }; +} + +export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring { + if (!vias.length) return EMPTY_REANCHORING; + + if (stopCount <= 2) return { vias: [], remove: vias.map((v) => v.id) }; + return collect(vias, (i) => { + if (position === 0) return i === 0 ? null : i - 1; + if (position === stopCount - 1) return i === position - 1 ? null : i; + if (i === position) return position - 1; + return i > position ? i - 1 : i; + }); +} + +export function reanchorByStopOrder(vias: AnchoredVia[], previousIds: number[], nextIds: number[]): Reanchoring { + if (!vias.length) return EMPTY_REANCHORING; + const moved: ReanchoredVia[] = []; + const remove: number[] = []; + for (const via of vias) { + const stopId = previousIds[via.after_order_index]!; + const next = stopId === undefined ? -1 : nextIds.indexOf(stopId); + + if (next === -1 || next >= nextIds.length - 1) { + remove.push(via.id); + continue; + } + if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next }); + } + return { vias: moved, remove }; +} + +export function reanchorAfterReorder(vias: AnchoredVia[], from: number, to: number, stopCount: number): Reanchoring { + if (!vias.length || from === to) return EMPTY_REANCHORING; + + if (stopCount <= 2) return EMPTY_REANCHORING; + const afterRemove = reanchorAfterRemove(vias, from, stopCount); + const dropped = new Set(afterRemove.remove); + const movedTo = new Map(afterRemove.vias.map((v) => [v.id, v.after_order_index] as const)); + + const shifted: AnchoredVia[] = vias + .filter((v) => !dropped.has(v.id)) + .map((v) => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index })); + const afterInsert = reanchorAfterInsert(shifted, to, () => true); + const finalIndex = new Map(afterInsert.vias.map((v) => [v.id, v.after_order_index] as const)); + + const result: ReanchoredVia[] = []; + for (const via of vias) { + if (dropped.has(via.id)) continue; + const mid = movedTo.get(via.id) ?? via.after_order_index; + const end = finalIndex.get(via.id) ?? mid; + if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end }); + } + return { vias: result, remove: afterRemove.remove }; +} + +export function isEmptyReanchoring(r: Reanchoring): boolean { + return r.vias.length === 0 && r.remove.length === 0; +} + +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)); +} + +export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']; + +export function serializeAvoid(classes: readonly RouteAvoidClass[]): string { + return AVOIDABLE.filter((cls) => classes.includes(cls)).join(','); +} diff --git a/shared/src/roadtrip/routeRun.ts b/shared/src/roadtrip/routeRun.ts new file mode 100644 index 000000000..854fe7aef --- /dev/null +++ b/shared/src/roadtrip/routeRun.ts @@ -0,0 +1,53 @@ +import { lineMetres, sliceAtMeters, projectOntoRoute } from './corridor'; +import type { RoadtripStop, RouteSegment, RouteVia, RoutedLeg } from './planning-types'; +import { legIndexForAlong } from './roadtripModel'; + +export const roadtripStopKey = (stop: RoadtripStop): string => + `${stop.lat.toFixed(5)},${stop.lng.toFixed(5)},${stop.legMode ?? ''},${stop.incomingLegMode ?? ''}`; +export const roadtripLegKey = (from: RoadtripStop, to: RoadtripStop): string => + `${roadtripStopKey(from)}>${roadtripStopKey(to)}`; + +export function foldRouteRun( + stops: RoadtripStop[], + stopAt: number[], + route: { coordinates: [number, number][]; legs: RouteSegment[]; vias?: RouteVia[] }, + mode: string, +): Record { + const spine = route.coordinates.map(([lat, lng]) => ({ lat, lng })); + const cuts: { key: string; from: number; to: number; seg: RouteSegment }[] = []; + let travelled = 0; + for (let index = 0; index < stops.length - 1; index++) { + const parts = route.legs.slice(stopAt[index], stopAt[index + 1]); + if (!parts.length) continue; + const merged = + parts.length === 1 + ? parts[0]! + : { + ...parts[0]!, + distance: parts.reduce((sum, leg) => sum + leg.distance, 0), + duration: parts.reduce((sum, leg) => sum + leg.duration, 0), + }; + const from = travelled; + travelled += merged.distance; + cuts.push({ key: roadtripLegKey(stops[index]!, stops[index + 1]!), from, to: travelled, seg: { ...merged, mode } }); + } + const scale = travelled > 0 ? lineMetres(spine) / travelled : 0; + const legs: Record = {}; + for (const cut of cuts) + legs[cut.key] = { + seg: cut.seg, + line: sliceAtMeters(spine, cut.from * scale, cut.to * scale).map((p) => [p.lat, p.lng]), + vias: [], + }; + for (const via of route.vias ?? []) { + if (!via.label && via.dwellSeconds == null) continue; + const along = (projectOntoRoute(via, spine)?.alongKm ?? 0) * 1000; + const index = legIndexForAlong( + cuts.map((cut) => cut.to), + along, + ); + const cut = cuts[index]; + if (cut) legs[cut.key]!.vias.push(via); + } + return legs; +} diff --git a/shared/src/roadtrip/units.ts b/shared/src/roadtrip/units.ts new file mode 100644 index 000000000..8599916f1 --- /dev/null +++ b/shared/src/roadtrip/units.ts @@ -0,0 +1,32 @@ +import type { DistanceUnit } from './planning-types'; + +const KM_TO_MI = 0.621371; +const M_TO_FT = 3.28084; + +export function getDistanceUnitLabel(unit: DistanceUnit): 'km' | 'mi' { + return unit === 'imperial' ? 'mi' : 'km'; +} + +export function formatElevation(meters: number, unit: DistanceUnit): string { + const safe = Number.isFinite(meters) ? meters : 0; + return unit === 'imperial' ? `${Math.round(safe * M_TO_FT)} ft` : `${Math.round(safe)} m`; +} + +export function convertDistance(km: number, unit: DistanceUnit): number { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0; + return unit === 'imperial' ? safeKm * KM_TO_MI : safeKm; +} + +export function formatDistance(km: number, unit: DistanceUnit): string { + const safeKm = Number.isFinite(km) ? Math.max(0, km) : 0; + + if (unit === 'metric' && safeKm < 1) { + return `${Math.round(safeKm * 1000)} m`; + } + const value = convertDistance(safeKm, unit); + const label = getDistanceUnitLabel(unit); + const rounded = Math.round(value * 10) / 10; + + const text = value > 0 && rounded === 0 ? '<0.1' : String(rounded); + return `${text} ${label}`; +} diff --git a/client/src/components/Roadtrip/vehicleRange.test.ts b/shared/src/roadtrip/vehicleRange.spec.ts similarity index 74% rename from client/src/components/Roadtrip/vehicleRange.test.ts rename to shared/src/roadtrip/vehicleRange.spec.ts index eaabec5e1..84eeaf91b 100644 --- a/client/src/components/Roadtrip/vehicleRange.test.ts +++ b/shared/src/roadtrip/vehicleRange.spec.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange' +import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from './vehicleRange'; + +import { describe, it, expect } from 'vitest'; /** * FE-ROADTRIP-VEHRANGE-001..013 — a range stated as the parts it is made of. @@ -11,119 +12,119 @@ import { rangeFromSpec, effectiveRangeKm, showSpec, storeSpec, specUnit } from ' 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) - }) + 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) - }) + 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) - }) + 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() - }) + 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() - }) + 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) - }) + 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() - }) -}) + 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) - }) + 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) - }) + 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() - }) -}) + 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) - }) + 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) - }) + 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') - }) + 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') + 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) - }) + 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) + 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) + 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) - }) + 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) - }) -}) + expect(showSpec('tankLitres', undefined, false)).toBeUndefined(); + expect(showSpec('tankLitres', 0, true)).toBeUndefined(); + expect(storeSpec('tankLitres', 0, true)).toBe(0); + }); +}); diff --git a/shared/src/roadtrip/vehicleRange.ts b/shared/src/roadtrip/vehicleRange.ts new file mode 100644 index 000000000..51ff05fcd --- /dev/null +++ b/shared/src/roadtrip/vehicleRange.ts @@ -0,0 +1,110 @@ +import type { VehicleKind } from './roadtripModel'; + +export interface VehicleSpec { + tankLitres?: number | null; + + litresPer100?: number | null; + + batteryKwh?: number | null; + + kwhPer100?: number | null; + + degradationPercent?: number | null; +} + +function positive(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; +} + +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; + + 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; +} + +export function effectiveRangeKm( + vehicle: VehicleKind | null | undefined, + spec: VehicleSpec, + typedKm: number | null | undefined, +): number | null { + return rangeFromSpec(vehicle, spec) ?? positive(typedKm); +} + +export type SpecKey = keyof VehicleSpec; + +const LITRES_PER_GALLON = 3.785411784; + +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 '%'; + } +} + +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; + + 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; +} + +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)); +} + +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/shared/tsdown.config.ts b/shared/tsdown.config.ts index c1e5ade88..f7edfd224 100644 --- a/shared/tsdown.config.ts +++ b/shared/tsdown.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'tsdown' +import { defineConfig } from 'tsdown'; export default defineConfig({ // Root barrel + i18n metadata barrel + one entry per locale (lazy-load chunks) - entry: ['src/index.ts', 'src/i18n/index.ts', 'src/i18n/*/index.ts'], + entry: ['src/index.ts', 'src/roadtrip/planning.ts', 'src/i18n/index.ts', 'src/i18n/*/index.ts'], format: ['cjs', 'esm'], dts: true, clean: true, @@ -10,4 +10,4 @@ export default defineConfig({ neverBundle: ['zod'], }, target: false, -}) +}); diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index b17888cfd..146bb03dc 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -185,6 +185,27 @@ Requires `journey:read` or `journey:write` scope. --- +### Roadtrip + +The Roadtrip addon must be enabled. These tools work without an open browser. The external assistant chooses places based on the traveller's interests, uses the existing trip, day, place and assignment tools to save them, and recalculates to check the result. + +| Tool | Purpose | Scope | +|---|---|---| +| `get_roadtrip_context` | Saved days, visits, coordinates, stays, pinned times, vehicle preferences, route profiles, vias, tracks and manual boundaries | trips:read | +| `calculate_roadtrip` | Calculated days, arrivals, departures, automatic pauses, driving warnings and range warnings; optional geometry | trips:read | +| `get_roadtrip_settings` | Personal driving preferences | settings:read | +| `update_roadtrip_settings` | Patch personal driving preferences, preserving other settings | settings:write | +| `search_roadtrip_corridor` | Fuel, charging, rest areas, campsites, food, sights or hotels along a day | trips:read | +| `update_route_via` | Move an existing routing handle and optionally change its outgoing leg | trips:write | + +Driving preferences include daily times, day-ending mode, leg/day driving limits, fuel or electric vehicle specifications, fallback range, fill percentage, avoidance and route display. They belong to the requesting account and apply across its trips. Manual endings belong to the shared trip. Only authorized trip editors can change visits, vias or endings. Fixed visit times retain priority over automatic times. Turning daily travel times off preserves saved endings but stops applying them. + +Calculation distances are metres, route durations seconds, and stays minutes. Settings use kilometres, litres, kWh, consumption per 100 km and percentages regardless of display units. Zero clears a numeric limit; an empty daily time disables the automatic window. Vehicle specifications take precedence over fallback range when complete. The optional calculation settings are a preview and are never saved. Calculations support up to 150 visits and 100 waypoints per routing run (30 for plugin profiles). Routing calls are paced and cached. Missing coordinates, provider failures and schedule conflicts are reported explicitly; incomplete totals must not be presented as a complete itinerary. Avoidance is a routing preference, and `avoidMissed` identifies requested classes that could not be avoided, including when Valhalla falls back to OSRM. + +Corridor results include source attribution, distance along/from the route, failed areas and truncated areas. Follow `nextOffset` for remaining search rectangles. Filters include name or brand, socket type, minimum known charging power and `fromKm`/`toKm`. Unknown charging power remains unknown. Search never adds places automatically. Use the returned place information with `create_and_assign_place`, then move or reorder the assignment and re-anchor vias as needed. Recalculate after editing. + +Existing `list_route_vias`, `add_route_via`, `add_route_vias`, `reanchor_route_vias` and `remove_route_via` manage scenic detours and followed tracks. `list_day_boundaries`, `set_day_boundary` and `set_assignment_end_day` manage manual endings. The Places tool `import_trip_gpx` accepts GPX XML up to one million characters, uses the standard importer and requires places:write. It imports waypoints, routes and tracks without assigning them to days; `export_trip_gpx` exports the trip. Stay durations and place-level time defaults can be cleared with null through `update_place`. + ## Addon-gated resources Resources provide read-only access via `trek://` URIs. The following resources require their addon to be enabled. diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 6f1457284..d26f6378f 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -67,7 +67,7 @@ While online, drag an end-of-day map label along the driving route or onto a vis The map marks pauses with a moon and day number beside a place or above the route. These markers disappear when zoomed out beyond level 6. -> **AI / MCP:** Pauses are calculated in the Roadtrip view and are not stored as places or bookings. `set_assignment_end_day` sets or clears the boolean `end_day` on a specific visit; it takes effect only with daily travel times enabled. `list_day_boundaries` and `set_day_boundary` read, set or clear dragged day endings, anchored to assignment IDs and a fraction of the route. Place and assignment tools continue to read the original assignments and manually entered times. +> **AI / MCP:** `get_roadtrip_context` reads saved days, visits, pins, stays, vias, followed tracks and day endings. `calculate_roadtrip` calculates arrivals, day splits, driving limits and vehicle-range warnings on the server, using the same planning logic as the browser. No open TREK tab is needed. Pauses remain calculated markers, never places or bookings. `get_roadtrip_settings` and `update_roadtrip_settings` read and change your personal driving preferences; these apply to all your trips and update connected views. Place and assignment tools edit stops, stay durations, times and ordering. `set_assignment_end_day` and `set_day_boundary` set or clear individual day endings. `search_roadtrip_corridor` finds stops along a calculated day, with name, charging and route-section filters. `import_trip_gpx` imports tracks; the via tools edit their routing handles. See [MCP-Addon-Tools](MCP-Addon-Tools) for units, scopes and incomplete results. ## Mobile Layout