fix(roadtrip): the range warning and the per-leg figures in the server plan

deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.

The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
This commit is contained in:
Maurice
2026-09-13 23:22:52 +02:00
parent 61880ce7d6
commit f4380727c7
5 changed files with 150 additions and 10 deletions
@@ -15,6 +15,9 @@ import {
parseClock,
effectiveRangeKm,
parseAvoid,
formatDistance,
formatDurationShort,
type DistanceUnit,
type RoadtripStop,
type PlanDay,
type RoutedLeg,
@@ -133,6 +136,8 @@ export class RoadtripPlanService {
if (context.visits.length > 150)
throw new HttpException({ error: 'This trip exceeds the 150-visit calculation limit.' }, 400);
const asked = new Set<string>();
const distanceUnit: DistanceUnit =
this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric';
const fetchRun = async (stops: RoadtripStop[], dayId: number, profile: string) => {
const points: { lat: number; lng: number }[] = [];
const stopAt: number[] = [];
@@ -152,12 +157,28 @@ export class RoadtripPlanService {
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],
}));
// Spreading the run's own seg first carried its whole-route text and
// midpoint onto every leg of a multi-stop run: `part` only overrides the
// metres and the seconds, so leg one of a 480 km day reported 100 km with
// "480 km" printed beside it. The four texts and the midpoint are per leg
// now, the way the browser has always computed them — and in the reader's
// own unit, rather than the router's hard-coded kilometres.
const legs = routed.parts.map((part, index) => {
const from = [points[index].lat, points[index].lng] as [number, number];
const to = [points[index + 1].lat, points[index + 1].lng] as [number, number];
const durationText = formatDurationShort(part.duration);
return {
...routed.leg.seg,
...part,
from,
to,
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as [number, number],
distanceText: formatDistance(part.distance / 1000, distanceUnit),
durationText,
drivingText: durationText,
walkingText: durationText,
};
});
Object.assign(
allLegs,
foldRouteRun(stops, stopAt, { coordinates: routed.leg.line, legs, vias: routed.leg.vias }, profile),
@@ -236,7 +257,7 @@ export class RoadtripPlanService {
vehicleKind,
connectDays,
boundaries: context.boundaries,
distanceUnit: this.settings.getUserSettings(userId).distance_unit === 'imperial' ? 'imperial' : 'metric',
distanceUnit,
labels: { start: 'Continue journey', end: 'End of day' },
});
return {
@@ -102,9 +102,11 @@ describe('roadtrip preferences', () => {
expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow();
expect(s.preferenceDb.run).not.toHaveBeenCalled();
s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
// The fourth argument is the socket that saved, so the tab that made the
// change is left out of its own echo. Undefined here: no header was sent.
expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', {
preferences: s.preferences.read(10),
});
}, undefined);
expect(s.preferences.read(10).roadtrip_range_km).toBe(100);
});
it.each([
+104
View File
@@ -0,0 +1,104 @@
/**
* ROADTRIP-ASSEMBLE-001..003 — what the assembler does with the model's warnings.
*
* The model itself is pinned by roadtripModel.spec.ts. This file pins the
* wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes
* a two-slot "does this stop refuel" pair, slot 0 for the stop the leg leaves
* and slot 1 for the stop it arrives at, and the assembler hard-coded slot 1 to
* false. The suppression the model was written for could therefore never fire
* through it, so a day that stops at a charger 700 km in reported running dry
* at the charger, and a run of warnings that should collapse to one did not.
*/
import { assembleRoadtrip } from './assemble';
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
import { describe, it, expect } from 'vitest';
const stop = (over: Partial<RoadtripStop> & { ownerIndex: number }): RoadtripStop => ({
assignmentId: 100 + over.ownerIndex,
ownerDayId: 1,
placeId: 200 + over.ownerIndex,
name: `Stop ${over.ownerIndex}`,
lat: 50 + over.ownerIndex,
lng: 10,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
});
const leg = (km: number): RoutedLeg => ({
seg: {
mid: [50, 10],
from: [50, 10],
to: [51, 10],
distance: km * 1000,
duration: km * 40,
walkingText: '',
drivingText: '',
distanceText: `${km} km`,
mode: 'driving',
},
line: [
[50, 10],
[51, 10],
],
vias: [],
});
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)}`;
function assemble(stops: RoadtripStop[], legs: number[]) {
const day: PlanDay = { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops };
const allLegs: Record<string, RoutedLeg> = {};
legs.forEach((km, i) => {
allLegs[legKey(stops[i], stops[i + 1])] = leg(km);
});
return assembleRoadtrip({
plan: [day],
quietDays: [],
window: null,
distanceUnit: 'km',
allLegs,
snapByDay: {},
missedByDay: {},
loading: false,
// 600 km on a full battery, and no daily driving ceiling to muddy the warnings.
limits: { rangeKm: 600 },
vehicleKind: 'electric',
connectDays: false,
boundaries: [],
labels: { start: 'start', end: 'end' },
});
}
const rangeWarnings = (routes: ReturnType<typeof assemble>) =>
routes.days[0].driveWarnings.filter((w) => w.code === 'range');
describe('assembleRoadtrip drive warnings', () => {
it('ROADTRIP-ASSEMBLE-001: a charger at the end of an out-of-range leg answers for it', () => {
const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1, stopType: 'charging' })];
expect(rangeWarnings(assemble(stops, [700]))).toEqual([]);
});
it('ROADTRIP-ASSEMBLE-002: an ordinary stop at the end of the same leg still warns', () => {
const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 })];
// The warning belongs to the stop the leg reaches, which is where the
// traveller would be standing when the battery ran out.
expect(rangeWarnings(assemble(stops, [700]))).toEqual([expect.objectContaining({ index: 1 })]);
});
it('ROADTRIP-ASSEMBLE-003: the budget resets at the charger, so the leg after it is measured fresh', () => {
const stops = [stop({ ownerIndex: 0 }), stop({ ownerIndex: 1, stopType: 'charging' }), stop({ ownerIndex: 2 })];
// 500 after a charge is inside a 600 km range, however far the day drove
// before it.
expect(rangeWarnings(assemble(stops, [700, 500]))).toEqual([]);
});
});
+14 -2
View File
@@ -176,7 +176,15 @@ export function assembleRoadtrip({
for (let i = 0; i < stops.length; i++) {
const incoming = inboundAt.get(i);
if (incoming?.seg) {
const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm);
// Slot 1 is the stop the leg ARRIVES at, which for an inbound leg is this
// one. Hard-coded false, the range warning could never be suppressed by
// the very charger or petrol station that resolves it.
const incomingDrive = deriveDriveWarnings(
[incoming.seg],
[false, refuelsRange(stops[i]!.stopType, vehicleKind)],
limits,
carryKm,
);
carryKm = incomingDrive.carryKm;
drive.warnings.push(...incomingDrive.warnings.map((w) => ({ ...w, index: i })));
for (const dry of incomingDrive.emptyAt) {
@@ -191,7 +199,11 @@ export function assembleRoadtrip({
const leg = routed[i];
const outgoing = deriveDriveWarnings(
i < stops.length - 1 ? [leg?.seg] : [],
[refuelsRange(stops[i]!.stopType, vehicleKind), false],
// Departure, then arrival: this leg leaves stop i and reaches stop i + 1,
// so a range warning belongs to the next stop and is the next stop's to
// answer for. With false here, a day that stops at a charger 700 km in
// still reported running dry at the charger.
[refuelsRange(stops[i]!.stopType, vehicleKind), refuelsRange(stops[i + 1]?.stopType, vehicleKind)],
limits,
carryKm,
[stops[i]!.fillPercent],
+1
View File
@@ -3,6 +3,7 @@ export * from './roadtripModel';
export * from './corridor';
export * from './nightSpill';
export * from './dayWindow';
export * from './units';
export * from './vehicleRange';
export * from './assemble';
export * from './routeRun';