mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): the day view drives the road picked in the road trip
A chosen alternative or a hand-bent drive is stored as vias, and only the road trip rail routed through them. The day route on the map, its connectors in the day list and on the phone, and the overview of all days asked the router for the bare stops, so they drew and measured the default road again. They now thread the same vias between two stops that follow each other in the stored list, with the anchor counted the way the rail counts it, and fold the answer back to one leg per stop pair. Only while the road trip addon is on; avoid settings and the routing engine stay with the rail. Refs #2580
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"@typescript-eslint/no-misused-promises": 876,
|
||||
"no-console": 88,
|
||||
"complexity": 231,
|
||||
"@typescript-eslint/no-explicit-any": 149,
|
||||
"@typescript-eslint/no-explicit-any": 148,
|
||||
"trek/no-swallowed-catch": 135,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1097,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5381,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 3001,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 3000,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1308,
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface DayRoutePoint {
|
||||
incoming_leg_transport_mode?: string | null
|
||||
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
|
||||
hotel?: 'morning' | 'evening'
|
||||
/** Only on a planned stop: its assignment, which the road trip's vias are filed against. */
|
||||
assignmentId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +86,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'tour'; start: { lat: number; lng: number } | null; end: { lat: number; lng: number } | null; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
@@ -99,7 +101,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
}]
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
kind: 'place', id: a.id, lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
||||
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
||||
@@ -144,7 +146,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming, assignmentId: entry.id })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types';
|
||||
import {
|
||||
dayViaLookup,
|
||||
foldViaLegs,
|
||||
roadtripStopList,
|
||||
routeLeg,
|
||||
threadVias,
|
||||
viaKeyOf,
|
||||
viasOnDayLeg,
|
||||
} from './dayRouteVias';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator');
|
||||
|
||||
const leg = (distance: number): RouteSegment => ({
|
||||
mid: [0, 0],
|
||||
from: [distance, 0],
|
||||
to: [distance + 1, 0],
|
||||
distance,
|
||||
duration: distance / 10,
|
||||
distanceText: '',
|
||||
durationText: '',
|
||||
walkingText: '',
|
||||
drivingText: '',
|
||||
});
|
||||
|
||||
const via = (dayId: number, afterOrderIndex: number, lat: number, sequence = 0): RoadtripVia =>
|
||||
({
|
||||
id: lat,
|
||||
day_id: dayId,
|
||||
after_order_index: afterOrderIndex,
|
||||
sequence,
|
||||
lat,
|
||||
lng: 0,
|
||||
created_at: '',
|
||||
}) as RoadtripVia;
|
||||
|
||||
const stop = (id: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ id, day_id: 1, order_index: order, place: buildPlace({ lat: 48 + order, lng: 2 }), ...extra });
|
||||
|
||||
// Stored order: hotel row, Paris, a place without coordinates, Lille, Brussels.
|
||||
const STORED: AssignmentsMap = {
|
||||
'1': [
|
||||
stop(14, 3),
|
||||
stop(11, 1),
|
||||
stop(10, 0, { accommodation_id: 5 }),
|
||||
buildAssignment({
|
||||
id: 12,
|
||||
day_id: 1,
|
||||
order_index: 2,
|
||||
place: buildPlace({ lat: null as unknown as number, lng: null as unknown as number }),
|
||||
}),
|
||||
stop(13, 2.5),
|
||||
],
|
||||
};
|
||||
|
||||
/** The lookup of a trip that has vias, which is never null. */
|
||||
function lookupFor(viasByDay: Record<number, RoadtripVia[]>) {
|
||||
const lookup = dayViaLookup(STORED, viasByDay);
|
||||
if (!lookup) throw new Error('expected a lookup');
|
||||
return lookup;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
|
||||
describe('dayRouteVias (#2580)', () => {
|
||||
it('FE-MAP-DRV-001: the stop list is the stored order with coordinates, hidden rows included', () => {
|
||||
expect(roadtripStopList(STORED['1']).map((a) => a.id)).toEqual([10, 11, 13, 14]);
|
||||
expect(roadtripStopList(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-002: a leg gets the vias filed behind its first stop, in sequence', () => {
|
||||
const lookup = lookupFor({ 1: [via(1, 1, 49.6, 1), via(1, 1, 49.3, 0), via(1, 2, 50.5)] });
|
||||
expect(lookup(1, 11, 13).map((v) => v.lat)).toEqual([49.3, 49.6]);
|
||||
expect(lookup(1, 13, 14).map((v) => v.lat)).toEqual([50.5]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-003: two stops that do not follow each other in the stored list get none', () => {
|
||||
const lookup = lookupFor({ 1: [via(1, 1, 49.3)] });
|
||||
// Paris to Brussels skips Lille: the via belongs to the leg to Lille.
|
||||
expect(lookup(1, 11, 14)).toEqual([]);
|
||||
expect(lookup(1, 13, 11)).toEqual([]);
|
||||
expect(lookup(1, 999, 13)).toEqual([]);
|
||||
expect(lookup(2, 11, 13)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-004: a trip without vias routes as before', () => {
|
||||
expect(dayViaLookup(STORED, undefined)).toBeNull();
|
||||
expect(dayViaLookup(STORED, {})).toBeNull();
|
||||
expect(dayViaLookup(STORED, { 1: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-005: a ride, a hotel bookend or a missing lookup bends nothing', () => {
|
||||
const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] });
|
||||
expect(viasOnDayLeg(lookup, 1, 'driving', { assignmentId: 11 }, { assignmentId: 13 })).toHaveLength(1);
|
||||
expect(viasOnDayLeg(lookup, 1, 'walking', { assignmentId: 11 }, { assignmentId: 13 })).toHaveLength(1);
|
||||
expect(viasOnDayLeg(lookup, 1, 'train', { assignmentId: 11 }, { assignmentId: 13 })).toEqual([]);
|
||||
expect(viasOnDayLeg(lookup, 1, 'driving', { assignmentId: 11 }, {})).toEqual([]);
|
||||
expect(viasOnDayLeg(null, 1, 'driving', { assignmentId: 11 }, { assignmentId: 13 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-006: threading remembers where each stop went, folding gives one leg per stop pair', () => {
|
||||
const points = [
|
||||
{ lat: 1, lng: 0, k: 'a' },
|
||||
{ lat: 2, lng: 0, k: 'b' },
|
||||
{ lat: 3, lng: 0, k: 'c' },
|
||||
];
|
||||
const { waypoints, stopAt } = threadVias(points, (from) =>
|
||||
from.k === 'a'
|
||||
? [
|
||||
{ lat: 1.5, lng: 0 },
|
||||
{ lat: 1.7, lng: 0 },
|
||||
]
|
||||
: []
|
||||
);
|
||||
expect(waypoints.map((w) => w.lat)).toEqual([1, 1.5, 1.7, 2, 3]);
|
||||
expect(stopAt).toEqual([0, 3, 4]);
|
||||
|
||||
const folded = foldViaLegs([leg(1000), leg(2000), leg(3000), leg(4000)], stopAt, 'imperial');
|
||||
expect(folded.map((l) => l.distance)).toEqual([6000, 4000]);
|
||||
expect(folded[0].from).toEqual([1000, 0]);
|
||||
expect(folded[0].to).toEqual([3001, 0]);
|
||||
expect(folded[0].distanceText).toMatch(/mi/);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-007: legs are left alone when nothing was threaded or the answer does not line up', () => {
|
||||
const legs = [leg(1000), leg(2000)];
|
||||
expect(foldViaLegs(legs, [0, 1, 2], 'metric')).toBe(legs);
|
||||
expect(foldViaLegs(legs, [0, 2, 3], 'metric')).toBe(legs);
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-008: the via key changes with the road and not with the order of the list', () => {
|
||||
const a = { 1: [via(1, 0, 49.3)], 2: [via(2, 0, 50.1)] };
|
||||
expect(viaKeyOf({ 2: a[2], 1: a[1] })).toBe(viaKeyOf(a));
|
||||
expect(viaKeyOf({ ...a, 1: [via(1, 0, 49.4)] })).not.toBe(viaKeyOf(a));
|
||||
expect(viaKeyOf(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-009: one leg is routed through its vias and read back as one leg in its mode', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
legs: [leg(1000), leg(2000)],
|
||||
});
|
||||
const lookup = dayViaLookup(STORED, { 1: [via(1, 1, 49.3)] });
|
||||
const options = { profile: 'driving', tripId: 7, dayId: 1 };
|
||||
|
||||
const seg = await routeLeg(
|
||||
{ lat: 49, lng: 2, assignmentId: 11 },
|
||||
{ lat: 50.5, lng: 2, assignmentId: 13 },
|
||||
lookup,
|
||||
options,
|
||||
'metric'
|
||||
);
|
||||
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0]).toEqual([
|
||||
{ lat: 49, lng: 2 },
|
||||
{ lat: 49.3, lng: 0 },
|
||||
{ lat: 50.5, lng: 2 },
|
||||
]);
|
||||
expect(seg?.distance).toBe(3000);
|
||||
expect(seg?.mode).toBe('driving');
|
||||
});
|
||||
|
||||
it('FE-MAP-DRV-010: a leg the router cannot answer is undefined', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new Error('down'));
|
||||
expect(
|
||||
await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric')
|
||||
).toBeUndefined();
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValueOnce({ coordinates: [], distance: 0, duration: 0, legs: [] });
|
||||
expect(
|
||||
await routeLeg({ lat: 1, lng: 1 }, { lat: 2, lng: 2 }, null, { profile: 'driving', dayId: 1 }, 'metric')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { isCarrierMode, mergeRouteSegments, viasOnLeg } from '@trek/shared/roadtrip';
|
||||
import type { Assignment, AssignmentsMap, DistanceUnit, RouteSegment } from '../../types';
|
||||
import { calculateRouteWithLegs } from './RouteCalculator';
|
||||
|
||||
/**
|
||||
* The stops of a day as the road trip counts them, in the order it drives them.
|
||||
*
|
||||
* This is the index space a via's `after_order_index` lives in: the STORED list, sorted
|
||||
* by `order_index` and filtered to the rows with coordinates. The stop a lodging booking
|
||||
* put on the day and the service stops are counted too, even where the day list hides
|
||||
* them, because the rail and the server count them.
|
||||
*/
|
||||
export function roadtripStopList(dayAssignments: readonly Assignment[] | undefined): Assignment[] {
|
||||
return (dayAssignments ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.filter((a) => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number');
|
||||
}
|
||||
|
||||
/** The trip's vias, keyed by the day they are filed against. */
|
||||
export type ViasByDay = Record<number, readonly RoadtripVia[]>;
|
||||
|
||||
/** The vias shaping the drive between two assignments of a day, in driving order. */
|
||||
export type DayViaLookup = (dayId: number, fromAssignmentId: number, toAssignmentId: number) => readonly RoadtripVia[];
|
||||
|
||||
const NONE: readonly RoadtripVia[] = [];
|
||||
|
||||
/**
|
||||
* The road a traveller picked in the road trip, for the views that draw a day without it.
|
||||
*
|
||||
* A chosen alternative or a hand-bent drive is stored as vias filed against the stop
|
||||
* they leave, so the day view asks for the same points between the same two stops. Only
|
||||
* between two stops that follow each other in the stored list: with a stop the day list
|
||||
* hides in between, the vias belong to a leg the day view does not draw, and bending the
|
||||
* shortcut through them would be a road nobody chose.
|
||||
*
|
||||
* Null when the trip has no vias, so a trip without a road trip routes exactly as before.
|
||||
*/
|
||||
export function dayViaLookup(stored: AssignmentsMap, viasByDay: ViasByDay | undefined): DayViaLookup | null {
|
||||
if (!viasByDay || !Object.values(viasByDay).some((list) => list.length)) return null;
|
||||
const indexByDay = new Map<number, Map<number, number>>();
|
||||
return (dayId, fromAssignmentId, toAssignmentId) => {
|
||||
const vias = viasByDay[dayId];
|
||||
if (!vias?.length) return NONE;
|
||||
let index = indexByDay.get(dayId);
|
||||
if (!index) {
|
||||
index = new Map(roadtripStopList(stored[String(dayId)]).map((a, i) => [a.id, i]));
|
||||
indexByDay.set(dayId, index);
|
||||
}
|
||||
const from = index.get(fromAssignmentId);
|
||||
if (from === undefined || index.get(toAssignmentId) !== from + 1) return NONE;
|
||||
return viasOnLeg({ ownerDayId: dayId, ownerIndex: from }, {}, vias);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The vias of one leg of a drawn day: none unless both ends are planned stops, and none
|
||||
* on a ride, which is no road.
|
||||
*/
|
||||
export function viasOnDayLeg(
|
||||
lookup: DayViaLookup | null,
|
||||
dayId: number,
|
||||
mode: string,
|
||||
from: { assignmentId?: number },
|
||||
to: { assignmentId?: number }
|
||||
): readonly RoadtripVia[] {
|
||||
if (!lookup || isCarrierMode(mode) || from.assignmentId == null || to.assignmentId == null) return NONE;
|
||||
return lookup(dayId, from.assignmentId, to.assignmentId);
|
||||
}
|
||||
|
||||
/** A stable key for the via list, so a routing effect re-runs when the road changes and only then. */
|
||||
export function viaKeyOf(viasByDay: ViasByDay | undefined): string {
|
||||
return Object.entries(viasByDay ?? {})
|
||||
.map(
|
||||
([dayId, vias]) =>
|
||||
`${dayId}:${vias.map((v) => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`
|
||||
)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* The waypoints to ask the router for: the stops with the vias of each leg threaded in
|
||||
* between. `stopAt[i]` is the waypoint the i-th stop became, which is how the answer's
|
||||
* legs (one per waypoint pair) are folded back to one per stop pair.
|
||||
*/
|
||||
export function threadVias<P extends { lat: number; lng: number }>(
|
||||
points: readonly P[],
|
||||
viasBetween: (from: P, to: P) => readonly { lat: number; lng: number }[]
|
||||
): { waypoints: { lat: number; lng: number }[]; stopAt: number[] } {
|
||||
const waypoints: { lat: number; lng: number }[] = [];
|
||||
const stopAt: number[] = [];
|
||||
points.forEach((point, i) => {
|
||||
stopAt.push(waypoints.length);
|
||||
waypoints.push({ lat: point.lat, lng: point.lng });
|
||||
if (i < points.length - 1)
|
||||
for (const via of viasBetween(point, points[i + 1])) waypoints.push({ lat: via.lat, lng: via.lng });
|
||||
});
|
||||
return { waypoints, stopAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* The router's legs read as one leg per stop pair again. Untouched when nothing was
|
||||
* threaded, or when the answer does not have a leg per waypoint pair (a plugin route may
|
||||
* not), since the pieces could not be told apart then.
|
||||
*/
|
||||
export function foldViaLegs(legs: RouteSegment[], stopAt: number[], unit: DistanceUnit | undefined): RouteSegment[] {
|
||||
const waypointCount = stopAt.length ? stopAt[stopAt.length - 1] + 1 : 0;
|
||||
if (waypointCount === stopAt.length || legs.length !== waypointCount - 1) return legs;
|
||||
const folded: RouteSegment[] = [];
|
||||
for (let i = 0; i < stopAt.length - 1; i++) {
|
||||
const parts = legs.slice(stopAt[i], stopAt[i + 1]);
|
||||
if (parts.length)
|
||||
folded.push(mergeRouteSegments(parts, unit === 'imperial' ? 'imperial' : 'metric') as RouteSegment);
|
||||
}
|
||||
return folded;
|
||||
}
|
||||
|
||||
/** One end of a leg: a planned stop carries its assignment, which the vias are filed against. */
|
||||
export type LegEnd = { lat: number; lng: number; assignmentId?: number };
|
||||
|
||||
/**
|
||||
* The drive between two points of a day as one leg, bent through the road trip's vias
|
||||
* between them and tagged with the mode it was routed in. Undefined when the router has
|
||||
* no road for it.
|
||||
*/
|
||||
export async function routeLeg(
|
||||
from: LegEnd,
|
||||
to: LegEnd,
|
||||
lookup: DayViaLookup | null,
|
||||
options: Parameters<typeof calculateRouteWithLegs>[1] & { profile: string; dayId: number },
|
||||
unit: DistanceUnit | undefined
|
||||
): Promise<RouteSegment | undefined> {
|
||||
const vias = viasOnDayLeg(lookup, options.dayId, options.profile, from, to);
|
||||
try {
|
||||
const waypoints = [from, ...vias, to].map((p) => ({ lat: p.lat, lng: p.lng }));
|
||||
const r = await calculateRouteWithLegs(waypoints, options);
|
||||
const leg = foldViaLegs(r.legs, [0, vias.length + 1], unit)[0];
|
||||
return leg ? { ...leg, mode: options.profile } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -232,4 +232,26 @@ describe('tripRouteGeometry', () => {
|
||||
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
|
||||
expect(byRoad.map(d => d.day.id)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-013: a road picked in the road trip bends the overview and still counts as one leg (#2580)', async () => {
|
||||
// A lodging row hides in the plan but counts in the stored list, so the via behind
|
||||
// the first stop of day 1 is filed at 1.
|
||||
const hotel = buildAssignment({ day_id: 1, order_index: -1, accommodation_id: 3, place: buildPlace({ lat: 48.8, lng: 2.3 }) })
|
||||
const storedAssignments = { ...ASSIGNMENTS, '1': [hotel, ...ASSIGNMENTS['1']] }
|
||||
const viasByDay = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 48.95, lng: 2.6, created_at: '' }] }
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 0, duration: 0,
|
||||
legs: points.slice(1).map(() => leg(10000)),
|
||||
}))
|
||||
|
||||
const plan = planTripRoute({ ...input, storedAssignments, viasByDay }, 'driving')
|
||||
expect(plan[0].runs[0][0].waypoints.map(p => p.lat)).toEqual([48.86, 48.95, 48.90])
|
||||
expect(plan[1].runs[0][0].waypoints.map(p => p.lat)).toEqual([45.76, 45.80])
|
||||
|
||||
const summary = await routeTrip({ ...input, storedAssignments, viasByDay }, { profile: 'driving', tripId: 7, unit: 'metric' })
|
||||
expect(summary.days[0].segments).toHaveLength(1)
|
||||
expect(summary.days[0].distance).toBe(20000)
|
||||
expect(summary.days[1].distance).toBe(10000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from './dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Day, RouteSegment } from '../../types'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { AssignmentsMap, Day, DistanceUnit, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
export interface TripOverviewDay {
|
||||
@@ -48,8 +50,16 @@ export interface TripRouteSummary {
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
* exactly as the single-day route builds them. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
* exactly as the single-day route builds them. `waypoints` are the points with the road
|
||||
* trip's vias threaded in, and `stopAt` says which waypoint each point became. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] }
|
||||
|
||||
/** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */
|
||||
interface TripRouteInputs extends DayRouteInputs {
|
||||
/** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */
|
||||
storedAssignments?: AssignmentsMap
|
||||
viasByDay?: Record<number, readonly RoadtripVia[]>
|
||||
}
|
||||
|
||||
/** A day reduced to the routing requests it needs, in the order they draw. */
|
||||
export interface TripRoutePlanDay { day: Day; runs: Chunk[][]; tourLines?: [number, number][][] }
|
||||
@@ -62,14 +72,16 @@ export type TripRouteAnswers = Answer[][][]
|
||||
export const dayRouteColor = (day: Day): { line: string; casing: string } =>
|
||||
dayColor(day.day_number ?? 0)
|
||||
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string, dayId: number, viaLookup: DayViaLookup | null): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let i = 0
|
||||
while (i < run.length - 1) {
|
||||
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
||||
let end = i + 1
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
chunks.push({ points: run.slice(i, end + 1), mode })
|
||||
const points = run.slice(i, end + 1)
|
||||
const { waypoints, stopAt } = threadVias(points, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
chunks.push({ points, mode, waypoints, stopAt })
|
||||
i = end
|
||||
}
|
||||
return chunks
|
||||
@@ -83,7 +95,8 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p =
|
||||
* Pure and synchronous, so a caller can compare two plans (the map keys its routing
|
||||
* round on one) before spending a single request.
|
||||
*/
|
||||
export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
|
||||
export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
|
||||
const viaLookup = dayViaLookup(input.storedAssignments ?? input.assignments, input.viasByDay)
|
||||
return [...input.days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({
|
||||
@@ -98,7 +111,7 @@ export function planTripRoute(input: DayRouteInputs, profile: RouteProfileKey):
|
||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||
.map(({ day, runs, tourLines }) => ({
|
||||
day,
|
||||
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)),
|
||||
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile, day.id, viaLookup)),
|
||||
tourLines,
|
||||
}))
|
||||
}
|
||||
@@ -205,9 +218,11 @@ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
*/
|
||||
export async function routeTripLegs(
|
||||
plan: TripRoutePlanDay[],
|
||||
{ tripId, signal, onAnswer }: {
|
||||
{ tripId, signal, onAnswer, unit }: {
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
/** The unit a leg bent through vias has its text written again in. */
|
||||
unit?: DistanceUnit
|
||||
/** Called after each answer, for a caller that wants to draw as they arrive. */
|
||||
onAnswer?: (routed: Answer[][][]) => void
|
||||
},
|
||||
@@ -218,15 +233,14 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
const waypoints = chunk.points.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
@@ -261,12 +275,13 @@ export async function routeTripLegs(
|
||||
* requests against a shared router, and no document is worth that wait.
|
||||
*/
|
||||
export async function routeTrip(
|
||||
input: DayRouteInputs,
|
||||
{ profile, tripId, signal, timeoutMs }: {
|
||||
input: TripRouteInputs,
|
||||
{ profile, tripId, signal, timeoutMs, unit }: {
|
||||
profile: RouteProfileKey
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
unit?: DistanceUnit
|
||||
},
|
||||
): Promise<TripRouteSummary> {
|
||||
const plan = planTripRoute(input, profile)
|
||||
@@ -275,7 +290,7 @@ export async function routeTrip(
|
||||
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
|
||||
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
|
||||
try {
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal })
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit })
|
||||
return summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
|
||||
@@ -278,6 +278,22 @@ describe('useTripRouteOverview', () => {
|
||||
expect(result.current.focusPoints).toBe(frame)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-015: a road picked in the road trip re-routes the overview through it (#2580)', async () => {
|
||||
const via = { id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.95, lng: 2.6, created_at: '' }
|
||||
const { result, rerender } = renderHook(
|
||||
({ viasByDay }: { viasByDay: Record<number, typeof via[]> }) =>
|
||||
useTripRouteOverview(7, DAYS, ASSIGNMENTS, [], [], 'driving', true, undefined, { storedAssignments: ASSIGNMENTS, viasByDay }),
|
||||
{ initialProps: { viasByDay: {} } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const asked = () => vi.mocked(calculateRouteWithLegs).mock.calls.map(call => call[0].map(p => p.lat))
|
||||
expect(asked()).toEqual([[48.86, 48.88], [45.76, 45.78]])
|
||||
|
||||
rerender({ viasByDay: { 1: [via] } })
|
||||
await waitFor(() => expect(asked()).toHaveLength(3))
|
||||
expect(asked()[2]).toEqual([48.86, 48.95, 48.88])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-013: switching the overview back on publishes a fresh frame', async () => {
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './tripRouteGeometry'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||
|
||||
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
||||
@@ -18,6 +19,12 @@ export interface TripRouteOverview extends TripRouteSummary {
|
||||
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
|
||||
const EMPTY_PLACES: Place[] = []
|
||||
|
||||
/** The road trip's vias and the stored day lists their anchors count in. */
|
||||
interface TripRouteVias {
|
||||
storedAssignments: AssignmentsMap
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
* distance (#1736).
|
||||
@@ -26,7 +33,8 @@ const EMPTY_PLACES: Place[] = []
|
||||
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
|
||||
* you are looking at it alone or at the whole trip. This hook is the React wrapper:
|
||||
* the planning and routing themselves live in `tripRouteGeometry`, so the PDF export
|
||||
* can draw the same trip without a component to hang a hook on.
|
||||
* can draw the same trip without a component to hang a hook on. A road picked in the road
|
||||
* trip is drawn here too (#2580).
|
||||
*/
|
||||
export function useTripRouteOverview(
|
||||
tripId: number | null,
|
||||
@@ -37,6 +45,7 @@ export function useTripRouteOverview(
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
places: Place[] = EMPTY_PLACES,
|
||||
vias?: TripRouteVias,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||
@@ -53,18 +62,20 @@ export function useTripRouteOverview(
|
||||
const frameRef = useRef<{ tripId: number | null; points: [number, number][] } | null>(null)
|
||||
const wasEnabled = useRef(false)
|
||||
|
||||
const storedAssignments = vias?.storedAssignments
|
||||
const viasByDay = vias?.viasByDay
|
||||
const plan = useMemo(
|
||||
() => (enabled
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places }, profile)
|
||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
|
||||
: []),
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places],
|
||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
|
||||
)
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs, tourLines }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
|
||||
.map(chunks => chunks.map(c => `${c.mode}>${c.waypoints.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
|
||||
.join('/')}:tours=${JSON.stringify(tourLines ?? [])}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
@@ -108,7 +119,7 @@ export function useTripRouteOverview(
|
||||
if (!loading && reframe) frameRef.current = { tripId, points: next.focusPoints }
|
||||
setResult({ ...next, focusPoints: loading ? frame : frameRef.current!.points, loading })
|
||||
}
|
||||
void routeTripLegs(plan, { tripId, signal: controller.signal, onAnswer: routed => publish(routed, true) })
|
||||
void routeTripLegs(plan, { tripId, signal: controller.signal, unit: distanceUnit, onAnswer: routed => publish(routed, true) })
|
||||
.then(routed => publish(routed, false))
|
||||
|
||||
return () => controller.abort()
|
||||
|
||||
@@ -11,7 +11,8 @@ import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink,
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import { reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, routeLeg, type LegEnd, type ViasByDay } from '../Map/dayRouteVias'
|
||||
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
@@ -131,6 +132,7 @@ export interface DayPlanSidebarProps {
|
||||
onNavigateToFiles?: () => void
|
||||
routeShown?: boolean
|
||||
routeProfile?: string
|
||||
roadtripViasByDay?: ViasByDay
|
||||
onToggleRoute?: () => void
|
||||
onSetRouteProfile?: (profile: string) => void
|
||||
onAddPlace?: () => void
|
||||
@@ -194,7 +196,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
tripId,
|
||||
trip, days, places, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, accommodations,
|
||||
onSelectDay, accommodations, roadtripViasByDay,
|
||||
onReorder,
|
||||
onAssignToDay, onMoveToDay,
|
||||
reservations,
|
||||
@@ -613,8 +615,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// Each run point carries the ORIGIN assignment's per-segment travel mode
|
||||
// (#1281): the leg from this point to the next is routed with `mode` (null =
|
||||
// inherit the day default). Transport endpoints carry no mode → day default.
|
||||
const runs: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }[] = []
|
||||
const runs: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; assignmentId?: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; assignmentId?: number }[] = []
|
||||
// A run is only a real drive when it holds an actual place. Two back-to-back
|
||||
// transports (e.g. two flights on one day) would otherwise pair the first's
|
||||
// arrival with the second's departure into a phantom airport→airport leg — the
|
||||
@@ -648,7 +650,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur = []
|
||||
curHasPlace = false
|
||||
}
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null })
|
||||
cur.push({ id: it.data.id, assignmentId: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null })
|
||||
curHasPlace = true
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
@@ -749,7 +751,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const legsByDay: Record<number, Record<number, RouteSegment>> = {}
|
||||
const hotelByDay: Record<number, { top?: { seg: RouteSegment; name: string; targetId?: number }; bottom?: { seg: RouteSegment; name: string; targetId?: number } }> = {}
|
||||
|
||||
// One cached OSRM/plugin call per waypoint pair; shares RouteCalculator's cache.
|
||||
// One cached OSRM/plugin call per leg, through the road trip's vias (#2580); shares RouteCalculator's cache.
|
||||
// tripId/dayId ride along for plugin route profiles (the server access-checks them).
|
||||
// Resolve a leg's mode (#1281): an explicit per-segment override wins, then
|
||||
// the day's saved default, then the live picker value. Sticky by design — the
|
||||
@@ -757,12 +759,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const dayDefaultMode = (dayId: number): string =>
|
||||
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
|
||||
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }, dayId: number, mode: string): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
return r.legs[0] ? { ...r.legs[0], mode } : undefined
|
||||
} catch { return undefined }
|
||||
}
|
||||
const viaLookup = dayViaLookup(assignments, roadtripViasByDay)
|
||||
const legBetween = (a: LegEnd, b: LegEnd, dayId: number, mode: string) =>
|
||||
routeLeg(a, b, viaLookup, { signal: controller.signal, profile: mode, tripId, dayId }, distanceUnit)
|
||||
|
||||
// Collect every leg of every day first, then fetch them a few at a time.
|
||||
// Each result is stored under its own key, so nothing here depends on the
|
||||
@@ -784,7 +783,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const to = run[i + 1]
|
||||
const mode = resolveLegMode(from, to, dfMode)
|
||||
tasks.push(async () => {
|
||||
const seg = await legBetween({ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }, dayId, mode)
|
||||
const seg = await legBetween(from, to, dayId, mode)
|
||||
if (seg) dayLegs[from.id] = seg
|
||||
})
|
||||
}
|
||||
@@ -829,7 +828,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
|
||||
// effect on the string is equivalent while staying stable across unrelated renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places])
|
||||
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { dayWindow } from './dayWindow'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { stayStartingOn } from '../../utils/dayMerge'
|
||||
import { staysAtTheirPlaces } from './nightBookend'
|
||||
import { viaKeyOf } from '../Map/dayRouteVias'
|
||||
|
||||
export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip'
|
||||
|
||||
@@ -402,15 +403,7 @@ export function useRoadtripRoutes(
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
const viaKey = useMemo(
|
||||
() => Object.entries(viasByDay)
|
||||
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
|
||||
// Only the order has to be stable — this is a cache key, not a list anybody reads
|
||||
// — but it has to be stable on purpose rather than by default.
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(';'),
|
||||
[viasByDay],
|
||||
)
|
||||
const viaKey = useMemo(() => viaKeyOf(viasByDay), [viasByDay])
|
||||
|
||||
const planKey = useMemo(
|
||||
() => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
|
||||
|
||||
@@ -6,18 +6,25 @@ import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../components/Map/dayRouteVias'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
|
||||
|
||||
const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||
const NO_VIAS: Record<number, RoadtripVia[]> = {}
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||
*
|
||||
* `roadtripVias` are the road trip's vias by day: a road picked there is the road this
|
||||
* day drives too (#2580). Absent, or empty while the road trip addon is off.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
||||
export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments'> & { roadtripVias?: Record<number, RoadtripVia[]> }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
||||
const viasByDay = tripStore.roadtripVias ?? NO_VIAS
|
||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
@@ -42,6 +49,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
|
||||
// refresh instead of showing stale cached text (#1300).
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
|
||||
const viaKey = useMemo(() => viaKeyOf(viasByDay), [viasByDay])
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
@@ -61,6 +69,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
places,
|
||||
})
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
// Anchors count the stored list, the hidden lodging and service rows included.
|
||||
const viaLookup = dayViaLookup(state.assignments || {}, viasByDay)
|
||||
|
||||
const straightLines = (): [number, number][][] =>
|
||||
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||
@@ -119,12 +129,13 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
const chunk = run.slice(i, end + 1)
|
||||
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
|
||||
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
const r = await calculateRouteWithLegs(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
|
||||
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
|
||||
// segment, which is how the phone timeline finds it (#2501).
|
||||
r.legs.forEach((leg, k) => {
|
||||
foldViaLegs(r.legs, stopAt, distanceUnit).forEach((leg, k) => {
|
||||
const hotelBookend = chunk[k + 1] ? hotelBookendOf(chunk[k], chunk[k + 1]) : undefined
|
||||
allLegs.push(hotelBookend ? { ...leg, mode, hotelBookend } : { ...leg, mode })
|
||||
})
|
||||
@@ -143,7 +154,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
|
||||
}
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
|
||||
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viasByDay])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
@@ -167,7 +178,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!selectedDayId) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viaKey])
|
||||
|
||||
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { RoadtripVia } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../tests/helpers/factories';
|
||||
import { resetAllStores } from '../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTripStore } from '../store/tripStore';
|
||||
import type { Assignment, RouteSegment } from '../types';
|
||||
import { useRouteCalculation } from './useRouteCalculation';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ route: vi.fn(), mirrorServiceStops: true }));
|
||||
vi.mock('../components/Map/RouteCalculator', async (importActual) => ({
|
||||
...(await importActual<typeof import('../components/Map/RouteCalculator')>()),
|
||||
calculateRouteWithLegs: mocks.route,
|
||||
}));
|
||||
vi.mock('./useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (settings: { roadtrip_service_stops_in_days: boolean }) => unknown) =>
|
||||
select({ roadtrip_service_stops_in_days: mocks.mirrorServiceStops }),
|
||||
}));
|
||||
|
||||
const segment = (distance: number): RouteSegment => ({
|
||||
mid: [0, 0],
|
||||
from: [0, 0],
|
||||
to: [0, 0],
|
||||
distance,
|
||||
duration: distance / 20,
|
||||
distanceText: '',
|
||||
durationText: '',
|
||||
walkingText: '',
|
||||
drivingText: '',
|
||||
});
|
||||
|
||||
const via = (afterOrderIndex: number, lat: number, lng: number, sequence = 0, dayId = 1): RoadtripVia =>
|
||||
({
|
||||
id: sequence + afterOrderIndex * 10,
|
||||
day_id: dayId,
|
||||
after_order_index: afterOrderIndex,
|
||||
sequence,
|
||||
lat,
|
||||
lng,
|
||||
created_at: '',
|
||||
}) as RoadtripVia;
|
||||
|
||||
/** Paris, Brussels; the chosen road bends east of the direct one. */
|
||||
function seed(extra: Assignment[] = []) {
|
||||
const paris = buildAssignment({
|
||||
id: 101,
|
||||
day_id: 1,
|
||||
order_index: 0,
|
||||
place: buildPlace({ id: 1, lat: 48.85, lng: 2.29 }),
|
||||
});
|
||||
const brussels = buildAssignment({
|
||||
id: 102,
|
||||
day_id: 1,
|
||||
order_index: 1,
|
||||
place: buildPlace({ id: 2, lat: 50.89, lng: 4.34 }),
|
||||
});
|
||||
const assignments = { '1': [paris, brussels, ...extra] };
|
||||
useTripStore.setState({ days: [buildDay({ id: 1 })], assignments, places: [], reservations: [] });
|
||||
return assignments;
|
||||
}
|
||||
|
||||
const askedLats = () => mocks.route.mock.calls.map((call) => call[0].map((point: { lat: number }) => point.lat));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mocks.mirrorServiceStops = true;
|
||||
mocks.route.mockReset().mockImplementation(async (points: Array<{ lat: number; lng: number }>) => ({
|
||||
coordinates: points.map((point) => [point.lat, point.lng]),
|
||||
legs: points.slice(1).map((_, i) => segment(100_000 + i * 10_000)),
|
||||
}));
|
||||
useSettingsStore.setState((state) => ({
|
||||
settings: { ...state.settings, optimize_from_accommodation: false, distance_unit: 'metric' },
|
||||
}));
|
||||
});
|
||||
|
||||
describe('useRouteCalculation with the road trip vias (#2580)', () => {
|
||||
it('FE-HOOK-ROUTE-VIA-001: routes the day through the road chosen in the road trip', async () => {
|
||||
const assignments = seed();
|
||||
const vias = { 1: [via(0, 49.85, 3.29)] };
|
||||
const { result } = renderHook(() => useRouteCalculation({ assignments, roadtripVias: vias }, 1, true, 'driving'));
|
||||
|
||||
await waitFor(() => expect(result.current.routeSegments).toHaveLength(1));
|
||||
expect(askedLats()).toEqual([[48.85, 49.85, 50.89]]);
|
||||
// One connector per stop pair, carrying the whole bent drive.
|
||||
expect(result.current.routeSegments[0].distance).toBe(210_000);
|
||||
expect(result.current.routeSegments[0].distanceText).toBe('210 km');
|
||||
expect(result.current.route).toEqual([
|
||||
[
|
||||
[48.85, 2.29],
|
||||
[49.85, 3.29],
|
||||
[50.89, 4.34],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-VIA-002: several vias on one leg keep their sequence', async () => {
|
||||
const assignments = seed();
|
||||
const vias = { 1: [via(0, 50.2, 3.9, 1), via(0, 49.5, 3.0, 0)] };
|
||||
renderHook(() => useRouteCalculation({ assignments, roadtripVias: vias }, 1, true, 'driving'));
|
||||
|
||||
await waitFor(() => expect(mocks.route).toHaveBeenCalled());
|
||||
expect(askedLats()).toEqual([[48.85, 49.5, 50.2, 50.89]]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-VIA-003: without vias the day routes exactly as before', async () => {
|
||||
const assignments = seed();
|
||||
const { result } = renderHook(() => useRouteCalculation({ assignments }, 1, true, 'driving'));
|
||||
|
||||
await waitFor(() => expect(result.current.routeSegments).toHaveLength(1));
|
||||
expect(askedLats()).toEqual([[48.85, 50.89]]);
|
||||
expect(result.current.routeSegments[0].distance).toBe(100_000);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-VIA-004: a hidden service stop between two stops keeps the vias off the shortcut', async () => {
|
||||
mocks.mirrorServiceStops = false;
|
||||
// Stored order: Paris (0), fuel stop (1), Brussels (2). The via leaves Paris towards
|
||||
// the fuel stop, a leg the day list does not draw.
|
||||
const fuel = buildAssignment({
|
||||
id: 103,
|
||||
day_id: 1,
|
||||
order_index: 0.5,
|
||||
place: buildPlace({ id: 3, lat: 49.4, lng: 2.8, stop_type: 'fuel' }),
|
||||
});
|
||||
const assignments = seed([fuel]);
|
||||
const vias = { 1: [via(0, 49.1, 2.5)] };
|
||||
renderHook(() => useRouteCalculation({ assignments, roadtripVias: vias }, 1, true, 'driving'));
|
||||
|
||||
await waitFor(() => expect(mocks.route).toHaveBeenCalled());
|
||||
expect(askedLats()).toEqual([[48.85, 50.89]]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-VIA-005: a lodging row the day list hides still counts in the anchor index', async () => {
|
||||
// Stored order: hotel row (0), Paris (1), Brussels (2). The via behind Paris is filed
|
||||
// at 1, because the road trip counts the hotel row the day list leaves out.
|
||||
const hotel = buildAssignment({
|
||||
id: 104,
|
||||
day_id: 1,
|
||||
order_index: -1,
|
||||
accommodation_id: 9,
|
||||
place: buildPlace({ id: 4, lat: 48.8, lng: 2.3 }),
|
||||
});
|
||||
const assignments = seed([hotel]);
|
||||
const vias = { 1: [via(1, 49.85, 3.29)] };
|
||||
renderHook(() => useRouteCalculation({ assignments, roadtripVias: vias }, 1, true, 'driving'));
|
||||
|
||||
await waitFor(() => expect(mocks.route).toHaveBeenCalled());
|
||||
expect(askedLats()).toEqual([[48.85, 49.85, 50.89]]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-VIA-006: a new choice redraws the day', async () => {
|
||||
const assignments = seed();
|
||||
let vias: Record<number, RoadtripVia[]> = {};
|
||||
const { rerender } = renderHook(() => useRouteCalculation({ assignments, roadtripVias: vias }, 1, true, 'driving'));
|
||||
await waitFor(() => expect(mocks.route).toHaveBeenCalledTimes(1));
|
||||
|
||||
vias = { 1: [via(0, 49.85, 3.29)] };
|
||||
rerender();
|
||||
|
||||
await waitFor(() => expect(mocks.route).toHaveBeenCalledTimes(2));
|
||||
expect(askedLats()[1]).toEqual([48.85, 49.85, 50.89]);
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,9 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
// only computes segments while the manual "show route" toggle is on (map), so
|
||||
// we run a dedicated, always-enabled calculation just for the connectors and
|
||||
// use only its segments — the polyline it produces is ignored here.
|
||||
// The road trip's vias bend these connectors the way the road was picked (#2580).
|
||||
const { routeSegments: connSegments } = useRouteCalculation(
|
||||
{ assignments } as unknown as Parameters<typeof useRouteCalculation>[0],
|
||||
{ assignments, roadtripVias: planner.roadtripVias.byDay },
|
||||
selectedDayId,
|
||||
true,
|
||||
routeProfile,
|
||||
|
||||
@@ -403,7 +403,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
routeShown={routeShown}
|
||||
routeProfile={routeProfile}
|
||||
routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay}
|
||||
onToggleRoute={() => setRouteShown(v => !v)}
|
||||
onSetRouteProfile={setRouteProfile}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
@@ -622,7 +622,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
|
||||
: <PlacesSidebar tripId={tripId} places={places} toursEnabled={toursEnabled} excludePlaceIds={toursEnabled ? tourPlaceIds : undefined} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,6 @@ const rt = vi.hoisted(() => ({
|
||||
},
|
||||
boundaryArgs: [] as unknown[],
|
||||
vias: { byDay: {} as Record<number, Array<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }>> },
|
||||
viasArgs: [] as unknown[],
|
||||
routes: {
|
||||
days: [] as Array<Record<string, unknown>>,
|
||||
quietDays: [] as unknown[],
|
||||
@@ -52,9 +51,6 @@ vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
vi.mock('../../components/Roadtrip/useDayBoundaries', () => ({
|
||||
useDayBoundaries: (...args: unknown[]) => { rt.boundaryArgs = args; return rt.boundaries },
|
||||
}))
|
||||
vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({
|
||||
useRoadtripVias: (...args: unknown[]) => { rt.viasArgs = args; return rt.vias },
|
||||
}))
|
||||
vi.mock('../../components/Roadtrip/useRefuelSearch', () => ({ useRefuelSearch: () => rt.refuel }))
|
||||
vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({
|
||||
useRoadtripRoutes: (...args: unknown[]) => { rt.routesArgs = args; return rt.routes },
|
||||
@@ -107,6 +103,7 @@ function baseOptions(over: Partial<Options> = {}): Options {
|
||||
return {
|
||||
tripId: 42, trip, can, toast, t,
|
||||
isMobile: false, activeTab: 'plan', enabledAddons: { roadtrip: true }, roadtripMode: true, roadtripSettings: {},
|
||||
roadtripVias: rt.vias as unknown as Options['roadtripVias'],
|
||||
days, storedAssignments: stored, assignments: plan, tripAccommodations: [], reservations: [],
|
||||
places: [lake, castle], visibleConnections: [], routeProfile: 'driving', mapPlaces: [], expandedDayIds: null,
|
||||
...over,
|
||||
@@ -143,7 +140,6 @@ describe('useRoadtripFeed', () => {
|
||||
expect(result.current.roadtripActive).toBe(false)
|
||||
expect(result.current.roadtripFeedActive).toBe(false)
|
||||
expect(rt.prefsArgs).toEqual([42, false])
|
||||
expect(rt.viasArgs).toEqual([42, false])
|
||||
expect(rt.boundaryArgs[1]).toBe(false)
|
||||
const [tripId, routedDays, routedAssignments, profile, , , , routedReservations] = rt.routesArgs
|
||||
expect(tripId).toBe(42)
|
||||
@@ -170,7 +166,6 @@ describe('useRoadtripFeed', () => {
|
||||
expect(result.current.roadtripFeedActive).toBe(true)
|
||||
expect(result.current.dailyTimesActive).toBe(true)
|
||||
expect(rt.prefsArgs).toEqual([42, true])
|
||||
expect(rt.viasArgs).toEqual([42, true])
|
||||
expect(rt.boundaryArgs).toEqual([42, true, plan])
|
||||
expect(rt.routesArgs[1]).toBe(days)
|
||||
expect(rt.routesArgs[2]).toBe(stored)
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import type { RoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { roadtripStopList } from '../../components/Map/dayRouteVias'
|
||||
import { useLoadRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -35,6 +36,8 @@ interface RoadtripFeedOptions extends Pick<PlannerBase, 'tripId' | 'trip' | 'can
|
||||
/** The trip's road trip switch, already off on a phone. */
|
||||
roadtripMode: boolean
|
||||
roadtripSettings: RoadtripPreferences
|
||||
/** The trip's vias, loaded by the planner because the day view draws them too. */
|
||||
roadtripVias: RoadtripVias
|
||||
days: TripStoreState['days']
|
||||
storedAssignments: TripStoreState['assignments']
|
||||
/** The day lists read as a plan, without the stops only the drive wants. */
|
||||
@@ -60,7 +63,7 @@ interface RoadtripFeedOptions extends Pick<PlannerBase, 'tripId' | 'trip' | 'can
|
||||
*/
|
||||
export function useRoadtripFeed(options: RoadtripFeedOptions) {
|
||||
const {
|
||||
tripId, trip, can, toast, t, isMobile, activeTab, enabledAddons, roadtripMode, roadtripSettings,
|
||||
tripId, trip, can, toast, t, isMobile, activeTab, enabledAddons, roadtripMode, roadtripSettings, roadtripVias,
|
||||
days, storedAssignments, assignments, tripAccommodations, reservations, places, visibleConnections,
|
||||
routeProfile, mapPlaces, expandedDayIds,
|
||||
} = options
|
||||
@@ -98,14 +101,6 @@ export function useRoadtripFeed(options: RoadtripFeedOptions) {
|
||||
for (const boundary of dayBoundaries.boundaries) await dayBoundaries.save(boundary.day_number, null)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
} : undefined
|
||||
// Fed whenever the addon is on, not only while the mode is being looked at.
|
||||
// The three handlers that re-anchor a day's vias (assign, remove, reorder)
|
||||
// are reachable from the place inspector in both modes, and with an empty list
|
||||
// they computed an empty correction and wrote nothing while the server kept
|
||||
// every via pointing at a position that had moved. Deleting a stop with the
|
||||
// switch off then left the detour on the wrong leg, for the traveller and for
|
||||
// a collaborator who never turned the mode on at all.
|
||||
const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip)
|
||||
const refuel = useRefuelSearch()
|
||||
const roadtripRoutes = useRoadtripRoutes(
|
||||
tripId,
|
||||
@@ -163,11 +158,7 @@ export function useRoadtripFeed(options: RoadtripFeedOptions) {
|
||||
* it also answers for a day with one stop or none, which is exactly the day a stop
|
||||
* gets pushed onto when a leg turns out too long.
|
||||
*/
|
||||
const roadtripStopsOf = useCallback((dayId: number) =>
|
||||
(storedAssignments[String(dayId)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'),
|
||||
const roadtripStopsOf = useCallback((dayId: number) => roadtripStopList(storedAssignments[String(dayId)]),
|
||||
[storedAssignments])
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,7 @@ import { usePlaceFormOpeners } from './usePlaceFormOpeners'
|
||||
import { useDayPlanEdits } from './useDayPlanEdits'
|
||||
import { useBookingEdits } from './useBookingEdits'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
|
||||
/**
|
||||
* Trip planner page logic, the big one. Owns the trip store wiring, addon
|
||||
@@ -422,20 +423,31 @@ export function useTripPlanner() {
|
||||
places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled,
|
||||
})
|
||||
|
||||
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||
// Fed whenever the addon is on, not only while the mode is being looked at.
|
||||
// The three handlers that re-anchor a day's vias (assign, remove, reorder)
|
||||
// are reachable from the place inspector in both modes, and with an empty list
|
||||
// they computed an empty correction and wrote nothing while the server kept
|
||||
// every via pointing at a position that had moved. Deleting a stop with the
|
||||
// switch off then left the detour on the wrong leg, for the traveller and for
|
||||
// a collaborator who never turned the mode on at all. The day route and the
|
||||
// overview draw them too, so a road picked in the road trip is the road the
|
||||
// day drives (#2580).
|
||||
const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip)
|
||||
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments, roadtripVias: roadtripVias.byDay }, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||
// Road trip mode already draws the whole trip its own way, so the overview stands
|
||||
// down there rather than drawing a second set of lines over it.
|
||||
const overviewActive = overviewShown && !roadtripMode
|
||||
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places)
|
||||
const overviewVias = useMemo(() => ({ storedAssignments, viasByDay: roadtripVias.byDay }), [storedAssignments, roadtripVias.byDay])
|
||||
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places, overviewVias)
|
||||
|
||||
// Called here so the road trip's effects keep their place, right after the overview's.
|
||||
const {
|
||||
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripVias, roadtripCorridor, refuel, followTrack,
|
||||
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, refuel, followTrack,
|
||||
roadtripPreferencesState, dailyTimesActive, dayBoundaries, resetDayBoundaries, dayBoundaryControls, saveRoadtripLimit,
|
||||
roadtripStopsOf, viaLiesBefore, viasAfterInsert, roadtripConnections, roadtripViaCounts,
|
||||
collapsedRoadtripDays, toggleRoadtripDay, roadtripMapLines, roadtripMapPlaces, roadtripLineColors, dawarichHiddenDates,
|
||||
} = useRoadtripFeed({
|
||||
tripId, trip, can, toast, t, enabledAddons, roadtripMode, roadtripSettings, isMobile, activeTab,
|
||||
tripId, trip, can, toast, t, enabledAddons, roadtripMode, roadtripSettings, roadtripVias, isMobile, activeTab,
|
||||
days, reservations, storedAssignments, assignments, tripAccommodations, places,
|
||||
mapPlaces, expandedDayIds, routeProfile, visibleConnections,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user