mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(planner): share the day route export and optimize slots between desktop and phone
dayRoute moves from the phone's lib into components/Planner. The desktop day plan builds its Google Maps and CoMaps stops with dayExportStops, and both optimizers put the reordered stops around the fixed ones with fillAroundLocked.
This commit is contained in:
@@ -11,7 +11,7 @@ import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink,
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
@@ -61,6 +61,7 @@ import { findTodayDayId } from './today'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { resolveLegMode } from './legMode'
|
||||
import { dayExportStops, fillAroundLocked } from './dayRoute'
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection'
|
||||
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
@@ -1161,12 +1162,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
||||
|
||||
// Merge: locked stay at their index, fill gaps with optimized
|
||||
const result = new Array(da.length)
|
||||
locked.forEach((a, i) => { result[i] = a })
|
||||
let qi = 0
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||
}
|
||||
const result = fillAroundLocked(da.length, locked, optimizedQueue)
|
||||
|
||||
await onReorder(dayId, result.map(a => a.id))
|
||||
const usedHotel = !!(anchors.start || anchors.end)
|
||||
@@ -1753,39 +1749,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
(dayCarriers.length === 0 || dayHasLocatedCarrier)
|
||||
)
|
||||
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
|
||||
/**
|
||||
* The day's located stops in planned order, bookended by the accommodation
|
||||
* the same way the drawn map route is (routeBookends is null when "optimize
|
||||
* from accommodation" is off), so hotels aren't dropped from an exported
|
||||
* route (#1372) — but only when the leg is real: no hotel prepended before an
|
||||
* early check-in-day stop, none appended after a post-check-out stop (#1465).
|
||||
* Names ride along for the deep links that can label a pin with one.
|
||||
*/
|
||||
const dayExportStops = (): NamedWaypoint[] => {
|
||||
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
// A flight, train, ferry or coach on a day without stops is the move itself,
|
||||
// located or not: the hotels at either end are joined by it, not by a road
|
||||
// worth handing to a map app (#2476).
|
||||
if (dayStops.length === 0 && dayCarriers.length > 0) return []
|
||||
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
|
||||
const lastAssignment = dayStops[dayStops.length - 1]
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
|
||||
// Same carrier gate as the drawn route (#2157): the exported link must not
|
||||
// start at a hotel you only reach tonight or lead back to one you left.
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasLocatedCarrier)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasLocatedCarrier)
|
||||
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
|
||||
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
|
||||
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
|
||||
}
|
||||
const showRouteTools = (isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable
|
||||
// Built once, for the day the tools show on. With no stop to hand over the
|
||||
// hand-offs would open nothing, so they are left out; a single stop still
|
||||
// opens as a pin (#2476).
|
||||
const exportStops = showRouteTools ? dayExportStops() : []
|
||||
// Built once, for the day the tools show on: the day's located stops in planned
|
||||
// order, bookended the way the drawn route is (#1372, #1465), with the same
|
||||
// carrier gate (#2157). With no stop to hand over the hand-offs would open
|
||||
// nothing, so they are left out; a single stop still opens as a pin (#2476).
|
||||
const exportStops = showRouteTools
|
||||
? dayExportStops(day, days, getDayAssignments(day.id), accommodations, optimizeFromAccommodation !== false, {
|
||||
located: dayHasLocatedCarrier,
|
||||
booked: dayCarriers.length > 0,
|
||||
})
|
||||
: []
|
||||
// Is this day's inline route currently on? Mobile toggles it per day (its
|
||||
// own expandedRouteDayIds entry); desktop uses the global Route toggle on
|
||||
// the selected day (#1374).
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Accommodation, Assignment, Day } from '../../types';
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, fillAroundLocked, optimizeDayOrder } from './dayRoute';
|
||||
|
||||
// FE-MOBILE-DAYROUTE-001 to FE-MOBILE-DAYROUTE-015
|
||||
|
||||
const days = [
|
||||
{ id: 10, day_number: 1 },
|
||||
{ id: 20, day_number: 2 },
|
||||
{ id: 30, day_number: 3 },
|
||||
] as unknown as Day[];
|
||||
|
||||
const hotel = {
|
||||
place_lat: 48.85,
|
||||
place_lng: 2.35,
|
||||
place_name: 'Hotel Lutetia',
|
||||
start_day_id: 10,
|
||||
end_day_id: 30,
|
||||
} as unknown as Accommodation;
|
||||
|
||||
const stop = (id: number, name: string, lat: number, lng: number): Assignment =>
|
||||
({ id, day_id: 20, order_index: id, place: { id, name, lat, lng } }) as unknown as Assignment;
|
||||
|
||||
const louvre = stop(1, 'Louvre', 48.86, 2.34);
|
||||
const orsay = stop(2, 'Orsay', 48.87, 2.33);
|
||||
|
||||
describe('dayExportStops', () => {
|
||||
it('FE-MOBILE-DAYROUTE-001: carries the stops in planned order, hotel-bookended and named', () => {
|
||||
expect(dayExportStops(days[1], days, [louvre, orsay], [hotel], true)).toEqual([
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
|
||||
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-002: drops the bookends when accommodation optimization is off', () => {
|
||||
const stops = dayExportStops(days[1], days, [louvre, orsay], [hotel], false);
|
||||
expect(stops.map((s) => s.name)).toEqual(['Louvre', 'Orsay']);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-003: a stop without coordinates cannot be exported', () => {
|
||||
const nowhere = { id: 3, day_id: 20, order_index: 3, place: { id: 3, name: 'TBD' } } as unknown as Assignment;
|
||||
const stops = dayExportStops(days[1], days, [louvre, nowhere], [], false);
|
||||
expect(stops.map((s) => s.name)).toEqual(['Louvre']);
|
||||
});
|
||||
|
||||
// The stay above records neither check-in nor check-out time: the #2157 shape.
|
||||
const homeNear = stop(4, 'Home', 48.9, 2.4);
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-006: with a carrier on the day, the not-yet-reached check-in hotel is not prepended (#2157)', () => {
|
||||
// Arrival day: you fly in, so the hotel may end the list but must not start it.
|
||||
const stops = dayExportStops(days[0], days, [homeNear], [hotel], true, { located: true });
|
||||
expect(stops.map((s) => s.name)).toEqual(['Home', 'Hotel Lutetia']);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-007: with a carrier on the day, the already-left check-out hotel is not appended (#2157)', () => {
|
||||
// Check-out day: the hotel starts the day, nothing routes back to it after the flight home.
|
||||
const stops = dayExportStops(days[2], days, [homeNear], [hotel], true, { located: true });
|
||||
expect(stops.map((s) => s.name)).toEqual(['Hotel Lutetia', 'Home']);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-008: without a carrier the no-time loop still closes (#2009 preserved)', () => {
|
||||
const stops = dayExportStops(days[0], days, [louvre], [hotel], true, { located: false });
|
||||
expect(stops.map((s) => s.name)).toEqual(['Hotel Lutetia', 'Louvre', 'Hotel Lutetia']);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-009: an edge stop out of drive range keeps the no-time hotel out even without a carrier (#2157)', () => {
|
||||
// No flight recorded, but "Home" is an ocean away, so the loop default is a guess
|
||||
// and does not get to route back across it.
|
||||
const farHome = stop(5, 'Home', 21.28, -157.83);
|
||||
const stops = dayExportStops(days[2], days, [farHome], [hotel], true);
|
||||
expect(stops.map((s) => s.name)).toEqual(['Hotel Lutetia', 'Home']);
|
||||
});
|
||||
|
||||
// Day 2 checks out of one stay and into the next, with no stop of its own.
|
||||
const hotelA = { ...hotel, place_name: 'Hotel A', start_day_id: 10, end_day_id: 20 } as Accommodation;
|
||||
const hotelB = {
|
||||
...hotel,
|
||||
place_lat: 53.55,
|
||||
place_lng: 9.99,
|
||||
place_name: 'Hotel B',
|
||||
start_day_id: 20,
|
||||
end_day_id: 30,
|
||||
} as Accommodation;
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-010: a flight between the two stays leaves nothing to export (#2476)', () => {
|
||||
// Located or not, the flight is how the day gets from one hotel to the other; a
|
||||
// driving route between them is the stretch nobody drives.
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { located: true, booked: true })).toEqual([]);
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toEqual([]);
|
||||
expect(dayGoogleMapsUrl(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toBeNull();
|
||||
expect(dayCoMapsUrl(days[1], days, [], [hotelA, hotelB], true, 'driving', { booked: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-011: without a booking the move is still the drive from one stay to the next (#1297)', () => {
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true).map((s) => s.name)).toEqual([
|
||||
'Hotel A',
|
||||
'Hotel B',
|
||||
]);
|
||||
// A stop of its own keeps the day exportable even with the flight booked.
|
||||
expect(
|
||||
dayExportStops(days[1], days, [louvre], [hotelA, hotelB], true, { located: true, booked: true }).map(
|
||||
(s) => s.name
|
||||
)
|
||||
).toContain('Louvre');
|
||||
});
|
||||
});
|
||||
|
||||
describe('day map links', () => {
|
||||
it('FE-MOBILE-DAYROUTE-004: both exports read the same stop list', () => {
|
||||
// One source for the bookend rules, so the two links can never disagree
|
||||
// about which hotel legs are real.
|
||||
const google = dayGoogleMapsUrl(days[1], days, [louvre, orsay], [hotel], true)!;
|
||||
const coMaps = dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking')!;
|
||||
for (const ll of ['48.85,2.35', '48.86,2.34', '48.87,2.33']) {
|
||||
expect(google).toContain(ll);
|
||||
expect(coMaps).toContain(ll);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-005: the CoMaps link travels in the mode it was given', () => {
|
||||
// Four bookended stops is more than a CoMaps route link can hold, so it
|
||||
// becomes pins, which is also why no mode appears here.
|
||||
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking')).toMatch(
|
||||
/^https:\/\/comaps\.at\/map\?v=1&/
|
||||
);
|
||||
// Two stops fit a route, and then the mode does apply.
|
||||
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [], false, 'walking')).toContain('type=pedestrian');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optimizeDayOrder', () => {
|
||||
const timed = (id: number, name: string, lat: number, lng: number, time: string): Assignment =>
|
||||
({ id, day_id: 20, order_index: id, place: { id, name, lat, lng, place_time: time } }) as unknown as Assignment;
|
||||
const unlocated = {
|
||||
id: 8,
|
||||
day_id: 20,
|
||||
order_index: 8,
|
||||
place: { id: 8, name: 'Somewhere', lat: null, lng: null },
|
||||
} as unknown as Assignment;
|
||||
const a = stop(3, 'A', 10, 10);
|
||||
const b = stop(4, 'B', 10, 30);
|
||||
const c = stop(5, 'C', 10, 20);
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-012: a timed stop keeps its slot and the rest are rerouted around it', () => {
|
||||
const breakfast = timed(9, 'Breakfast', 40, 40, '08:00');
|
||||
const result = optimizeDayOrder(days[1], days, [breakfast, a, b, c], [], false);
|
||||
expect(result?.order.map((x) => x.id)).toEqual([9, 3, 5, 4]);
|
||||
expect(result?.usedHotel).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-013: stops without coordinates fill the slots left over at the end', () => {
|
||||
const result = optimizeDayOrder(days[1], days, [unlocated, a, b, c], [], false);
|
||||
expect(result?.order.map((x) => x.id)).toEqual([3, 5, 4, 8]);
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-014: fewer than two movable located stops leave nothing to optimize', () => {
|
||||
expect(optimizeDayOrder(days[1], days, [a, unlocated], [], false)).toBeNull();
|
||||
expect(optimizeDayOrder(days[1], days, [timed(9, 'T', 1, 1, '08:00'), a], [], false)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-015: the hotel the day starts and ends at anchors the route when asked to', () => {
|
||||
const result = optimizeDayOrder(days[1], days, [louvre, orsay, stop(6, 'Opera', 48.871, 2.332)], [hotel], true);
|
||||
expect(result?.usedHotel).toBe(true);
|
||||
expect(result?.order.map((x) => x.id).sort()).toEqual([1, 2, 6]);
|
||||
expect(optimizeDayOrder(days[1], days, [louvre, orsay], [hotel], false)?.usedHotel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillAroundLocked', () => {
|
||||
it('FE-PLANNER-DAYROUTE-001: locked entries stay at their index and the queue fills the gaps in order', () => {
|
||||
const locked = new Map([
|
||||
[1, 'locked-1'],
|
||||
[3, 'locked-3'],
|
||||
]);
|
||||
expect(fillAroundLocked(5, locked, ['q1', 'q2', 'q3'])).toEqual(['q1', 'locked-1', 'q2', 'locked-3', 'q3']);
|
||||
expect(fillAroundLocked(2, new Map<number, string>(), ['x', 'y'])).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Accommodation, Assignment, Day } from '../../types';
|
||||
import {
|
||||
getAccommodationAnchors,
|
||||
getDayBookendHotels,
|
||||
shouldDrawEveningLeg,
|
||||
shouldDrawMorningLeg,
|
||||
} from '../../utils/dayOrder';
|
||||
import {
|
||||
generateCoMapsUrl,
|
||||
generateGoogleMapsUrl,
|
||||
optimizeRoute,
|
||||
type NamedWaypoint,
|
||||
type RouteProfileKey,
|
||||
} from '../Map/RouteCalculator';
|
||||
|
||||
/**
|
||||
* Day-route helpers shared by the desktop day plan, the phone's plan timeline and
|
||||
* its day sheet: the exact desktop rules (timed places stay anchored, hotel
|
||||
* bookends only when the leg is real, #1372/#1465), in one place so the surfaces
|
||||
* cannot drift apart.
|
||||
*/
|
||||
|
||||
export interface OptimizedDay {
|
||||
order: Assignment[];
|
||||
/** True when an accommodation anchored the start or end of the route. */
|
||||
usedHotel: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized order for a day's assignments: timed places keep their slot, the
|
||||
* remaining located stops are rerouted (optionally anchored to the day's
|
||||
* accommodation) and stops without coordinates fill the leftover slots.
|
||||
* Null when fewer than two movable located stops exist: nothing to optimize.
|
||||
*/
|
||||
export function optimizeDayOrder(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
fromAccommodation: boolean,
|
||||
dayHasCarrier?: boolean
|
||||
): OptimizedDay | null {
|
||||
const locked = new Map<number, Assignment>();
|
||||
const movable: Assignment[] = [];
|
||||
dayAssignments.forEach((a, idx) => {
|
||||
if (a.place?.place_time) locked.set(idx, a);
|
||||
else movable.push(a);
|
||||
});
|
||||
const withCoords = movable.filter((a) => a.place?.lat != null && a.place?.lng != null);
|
||||
if (withCoords.length < 2) return null;
|
||||
const noCoords = movable.filter((a) => a.place?.lat == null || a.place?.lng == null);
|
||||
const anchors = fromAccommodation
|
||||
? getAccommodationAnchors(
|
||||
day,
|
||||
days,
|
||||
accommodations,
|
||||
withCoords.map((a) => ({ lat: a.place!.lat!, lng: a.place!.lng! })),
|
||||
dayHasCarrier
|
||||
)
|
||||
: {};
|
||||
const optimized = optimizeRoute(
|
||||
withCoords.map((a) => ({ lat: a.place!.lat!, lng: a.place!.lng!, _assignmentId: a.id })),
|
||||
anchors
|
||||
)
|
||||
.map((p) => withCoords.find((a) => a.id === p._assignmentId)!)
|
||||
.filter(Boolean);
|
||||
const order = fillAroundLocked(dayAssignments.length, locked, [...optimized, ...noCoords]);
|
||||
return { order, usedHotel: Boolean(anchors.start || anchors.end) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A day's new order: the locked entries stay at their index and the queue fills
|
||||
* the free slots, in its own order.
|
||||
*/
|
||||
export function fillAroundLocked<T>(length: number, locked: Map<number, T>, queue: T[]): T[] {
|
||||
const order: T[] = new Array(length);
|
||||
locked.forEach((a, idx) => {
|
||||
order[idx] = a;
|
||||
});
|
||||
let qi = 0;
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
if (!order[i]) order[i] = queue[qi++];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the day's carrier bookings (flight, train, ferry, coach) tell the
|
||||
* exports. `located`: one has an endpoint with coordinates on the day, which
|
||||
* keeps the no-time bookend default from opening a leg out of a hotel only
|
||||
* reached tonight or back into one already left (#2157), same as the drawn
|
||||
* route. `booked`: one is booked on the day at all, located or not, which
|
||||
* leaves a day without stops with nothing to export: the hotels at either end
|
||||
* are joined by that booking, not by a road (#2476).
|
||||
*/
|
||||
export interface DayCarrier {
|
||||
located?: boolean;
|
||||
booked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's located stops in planned order, bookended by the morning/evening
|
||||
* accommodation exactly like the drawn route. Names ride along for the deep
|
||||
* links that can label a pin with one. See DayCarrier for what the day's
|
||||
* carrier bookings change.
|
||||
*/
|
||||
export function dayExportStops(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
carrier: DayCarrier = {}
|
||||
): NamedWaypoint[] {
|
||||
const located = dayAssignments.filter((a) => a.place?.lat != null && a.place?.lng != null);
|
||||
if (located.length === 0 && carrier.booked) return [];
|
||||
const stops = located.map((a) => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }));
|
||||
const bookends = bookendFromAccommodation ? getDayBookendHotels(day, days, accommodations) : null;
|
||||
const firstStop = located[0]
|
||||
? {
|
||||
isPlace: true,
|
||||
time: located[0].place?.place_time ?? null,
|
||||
lat: located[0].place!.lat!,
|
||||
lng: located[0].place!.lng!,
|
||||
}
|
||||
: undefined;
|
||||
const last = located[located.length - 1];
|
||||
const lastStop = last
|
||||
? { isPlace: true, time: last.place?.place_time ?? null, lat: last.place!.lat!, lng: last.place!.lng! }
|
||||
: undefined;
|
||||
const morning =
|
||||
bookends &&
|
||||
shouldDrawMorningLeg(bookends, day, firstStop, carrier.located) &&
|
||||
bookends.morning?.place_lat != null &&
|
||||
bookends.morning?.place_lng != null
|
||||
? { lat: bookends.morning.place_lat, lng: bookends.morning.place_lng, name: bookends.morning.place_name }
|
||||
: null;
|
||||
const evening =
|
||||
bookends &&
|
||||
shouldDrawEveningLeg(bookends, day, lastStop, carrier.located) &&
|
||||
bookends.evening?.place_lat != null &&
|
||||
bookends.evening?.place_lng != null
|
||||
? { lat: bookends.evening.place_lat, lng: bookends.evening.place_lng, name: bookends.evening.place_name }
|
||||
: null;
|
||||
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])];
|
||||
}
|
||||
|
||||
/** Google-Maps directions URL over the day's bookended stops. */
|
||||
export function dayGoogleMapsUrl(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
carrier: DayCarrier = {}
|
||||
): string | null {
|
||||
return (
|
||||
generateGoogleMapsUrl(
|
||||
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** The same stops handed to CoMaps for offline navigation, in the day's travel mode (#1904). */
|
||||
export function dayCoMapsUrl(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
profile: RouteProfileKey,
|
||||
carrier: DayCarrier = {}
|
||||
): string | null {
|
||||
return (
|
||||
generateCoMapsUrl(
|
||||
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier),
|
||||
profile
|
||||
) || null
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl } from './dayRoute'
|
||||
import type { Accommodation, Assignment, Day } from '../../../../types'
|
||||
|
||||
// FE-MOBILE-DAYROUTE-001 to FE-MOBILE-DAYROUTE-011
|
||||
|
||||
const days = [
|
||||
{ id: 10, day_number: 1 },
|
||||
{ id: 20, day_number: 2 },
|
||||
{ id: 30, day_number: 3 },
|
||||
] as unknown as Day[]
|
||||
|
||||
const hotel = {
|
||||
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
|
||||
start_day_id: 10, end_day_id: 30,
|
||||
} as unknown as Accommodation
|
||||
|
||||
const stop = (id: number, name: string, lat: number, lng: number): Assignment =>
|
||||
({ id, day_id: 20, order_index: id, place: { id, name, lat, lng } }) as unknown as Assignment
|
||||
|
||||
const louvre = stop(1, 'Louvre', 48.86, 2.34)
|
||||
const orsay = stop(2, 'Orsay', 48.87, 2.33)
|
||||
|
||||
describe('dayExportStops', () => {
|
||||
it('FE-MOBILE-DAYROUTE-001: carries the stops in planned order, hotel-bookended and named', () => {
|
||||
expect(dayExportStops(days[1], days, [louvre, orsay], [hotel], true)).toEqual([
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
|
||||
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
|
||||
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-002: drops the bookends when accommodation optimization is off', () => {
|
||||
const stops = dayExportStops(days[1], days, [louvre, orsay], [hotel], false)
|
||||
expect(stops.map(s => s.name)).toEqual(['Louvre', 'Orsay'])
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-003: a stop without coordinates cannot be exported', () => {
|
||||
const nowhere = { id: 3, day_id: 20, order_index: 3, place: { id: 3, name: 'TBD' } } as unknown as Assignment
|
||||
const stops = dayExportStops(days[1], days, [louvre, nowhere], [], false)
|
||||
expect(stops.map(s => s.name)).toEqual(['Louvre'])
|
||||
})
|
||||
|
||||
// The stay above records neither check-in nor check-out time — the #2157 shape.
|
||||
const homeNear = stop(4, 'Home', 48.9, 2.4)
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-006: with a carrier on the day, the not-yet-reached check-in hotel is not prepended (#2157)', () => {
|
||||
// Arrival day: you fly in, so the hotel may end the list but must not start it.
|
||||
const stops = dayExportStops(days[0], days, [homeNear], [hotel], true, { located: true })
|
||||
expect(stops.map(s => s.name)).toEqual(['Home', 'Hotel Lutetia'])
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-007: with a carrier on the day, the already-left check-out hotel is not appended (#2157)', () => {
|
||||
// Check-out day: the hotel starts the day, nothing routes back to it after the flight home.
|
||||
const stops = dayExportStops(days[2], days, [homeNear], [hotel], true, { located: true })
|
||||
expect(stops.map(s => s.name)).toEqual(['Hotel Lutetia', 'Home'])
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-008: without a carrier the no-time loop still closes (#2009 preserved)', () => {
|
||||
const stops = dayExportStops(days[0], days, [louvre], [hotel], true, { located: false })
|
||||
expect(stops.map(s => s.name)).toEqual(['Hotel Lutetia', 'Louvre', 'Hotel Lutetia'])
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-009: an edge stop out of drive range keeps the no-time hotel out even without a carrier (#2157)', () => {
|
||||
// No flight recorded, but "Home" is an ocean away — the loop default is a guess
|
||||
// and does not get to route back across it.
|
||||
const farHome = stop(5, 'Home', 21.28, -157.83)
|
||||
const stops = dayExportStops(days[2], days, [farHome], [hotel], true)
|
||||
expect(stops.map(s => s.name)).toEqual(['Hotel Lutetia', 'Home'])
|
||||
})
|
||||
|
||||
// Day 2 checks out of one stay and into the next, with no stop of its own.
|
||||
const hotelA = { ...hotel, place_name: 'Hotel A', start_day_id: 10, end_day_id: 20 } as Accommodation
|
||||
const hotelB = { ...hotel, place_lat: 53.55, place_lng: 9.99, place_name: 'Hotel B', start_day_id: 20, end_day_id: 30 } as Accommodation
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-010: a flight between the two stays leaves nothing to export (#2476)', () => {
|
||||
// Located or not, the flight is how the day gets from one hotel to the other; a
|
||||
// driving route between them is the stretch nobody drives.
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { located: true, booked: true })).toEqual([])
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toEqual([])
|
||||
expect(dayGoogleMapsUrl(days[1], days, [], [hotelA, hotelB], true, { booked: true })).toBeNull()
|
||||
expect(dayCoMapsUrl(days[1], days, [], [hotelA, hotelB], true, 'driving', { booked: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-011: without a booking the move is still the drive from one stay to the next (#1297)', () => {
|
||||
expect(dayExportStops(days[1], days, [], [hotelA, hotelB], true).map(s => s.name)).toEqual(['Hotel A', 'Hotel B'])
|
||||
// A stop of its own keeps the day exportable even with the flight booked.
|
||||
expect(dayExportStops(days[1], days, [louvre], [hotelA, hotelB], true, { located: true, booked: true }).map(s => s.name)).toContain('Louvre')
|
||||
})
|
||||
})
|
||||
|
||||
describe('day map links', () => {
|
||||
it('FE-MOBILE-DAYROUTE-004: both exports read the same stop list', () => {
|
||||
// One source for the bookend rules, so the two links can never disagree
|
||||
// about which hotel legs are real.
|
||||
const google = dayGoogleMapsUrl(days[1], days, [louvre, orsay], [hotel], true)!
|
||||
const coMaps = dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking')!
|
||||
for (const ll of ['48.85,2.35', '48.86,2.34', '48.87,2.33']) {
|
||||
expect(google).toContain(ll)
|
||||
expect(coMaps).toContain(ll)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOBILE-DAYROUTE-005: the CoMaps link travels in the mode it was given', () => {
|
||||
// Four bookended stops is more than a CoMaps route link can hold, so it
|
||||
// becomes pins — which is also why no mode appears here.
|
||||
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [hotel], true, 'walking'))
|
||||
.toMatch(/^https:\/\/comaps\.at\/map\?v=1&/)
|
||||
// Two stops fit a route, and then the mode does apply.
|
||||
expect(dayCoMapsUrl(days[1], days, [louvre, orsay], [], false, 'walking'))
|
||||
.toContain('type=pedestrian')
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
generateCoMapsUrl, generateGoogleMapsUrl, optimizeRoute,
|
||||
type NamedWaypoint, type RouteProfileKey,
|
||||
} from '../../../../components/Map/RouteCalculator'
|
||||
import {
|
||||
getAccommodationAnchors, getDayBookendHotels, shouldDrawEveningLeg, shouldDrawMorningLeg,
|
||||
} from '../../../../utils/dayOrder'
|
||||
import type { Accommodation, Assignment, Day } from '../../../../types'
|
||||
|
||||
/**
|
||||
* Day-route helpers shared by the plan timeline and the day sheet — the exact
|
||||
* desktop rules (timed places stay anchored, hotel bookends only when the leg
|
||||
* is real, #1372/#1465), extracted so both surfaces can't drift apart.
|
||||
*/
|
||||
|
||||
export interface OptimizedDay {
|
||||
order: Assignment[]
|
||||
/** True when an accommodation anchored the start or end of the route. */
|
||||
usedHotel: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized order for a day's assignments: timed places keep their slot, the
|
||||
* remaining located stops are rerouted (optionally anchored to the day's
|
||||
* accommodation) and stops without coordinates fill the leftover slots.
|
||||
* Null when fewer than two movable located stops exist — nothing to optimize.
|
||||
*/
|
||||
export function optimizeDayOrder(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
fromAccommodation: boolean,
|
||||
dayHasCarrier?: boolean,
|
||||
): OptimizedDay | null {
|
||||
const locked = new Map<number, Assignment>()
|
||||
const movable: Assignment[] = []
|
||||
dayAssignments.forEach((a, idx) => {
|
||||
if (a.place?.place_time) locked.set(idx, a)
|
||||
else movable.push(a)
|
||||
})
|
||||
const withCoords = movable.filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
if (withCoords.length < 2) return null
|
||||
const noCoords = movable.filter(a => a.place?.lat == null || a.place?.lng == null)
|
||||
const anchors = fromAccommodation
|
||||
? getAccommodationAnchors(day, days, accommodations,
|
||||
withCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })), dayHasCarrier)
|
||||
: {}
|
||||
const optimized = optimizeRoute(
|
||||
withCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, _assignmentId: a.id })),
|
||||
anchors,
|
||||
).map(p => withCoords.find(a => a.id === p._assignmentId)!).filter(Boolean)
|
||||
const queue = [...optimized, ...noCoords]
|
||||
const order: Assignment[] = new Array(dayAssignments.length)
|
||||
locked.forEach((a, idx) => { order[idx] = a })
|
||||
let qi = 0
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
if (!order[i]) order[i] = queue[qi++]
|
||||
}
|
||||
return { order, usedHotel: Boolean(anchors.start || anchors.end) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the day's carrier bookings (flight, train, ferry, coach) tell the
|
||||
* exports. `located`: one has an endpoint with coordinates on the day, which
|
||||
* keeps the no-time bookend default from opening a leg out of a hotel only
|
||||
* reached tonight or back into one already left (#2157), same as the drawn
|
||||
* route. `booked`: one is booked on the day at all, located or not, which
|
||||
* leaves a day without stops with nothing to export: the hotels at either end
|
||||
* are joined by that booking, not by a road (#2476).
|
||||
*/
|
||||
export interface DayCarrier {
|
||||
located?: boolean
|
||||
booked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's located stops in planned order, bookended by the morning/evening
|
||||
* accommodation exactly like the drawn route. Names ride along for the deep
|
||||
* links that can label a pin with one. See DayCarrier for what the day's
|
||||
* carrier bookings change.
|
||||
*/
|
||||
export function dayExportStops(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
carrier: DayCarrier = {},
|
||||
): NamedWaypoint[] {
|
||||
const located = dayAssignments.filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
if (located.length === 0 && carrier.booked) return []
|
||||
const stops = located.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
|
||||
const bookends = bookendFromAccommodation ? getDayBookendHotels(day, days, accommodations) : null
|
||||
const firstStop = located[0]
|
||||
? { isPlace: true, time: located[0].place?.place_time ?? null, lat: located[0].place!.lat!, lng: located[0].place!.lng! }
|
||||
: undefined
|
||||
const last = located[located.length - 1]
|
||||
const lastStop = last
|
||||
? { isPlace: true, time: last.place?.place_time ?? null, lat: last.place!.lat!, lng: last.place!.lng! }
|
||||
: undefined
|
||||
const morning = bookends && shouldDrawMorningLeg(bookends, day, firstStop, carrier.located)
|
||||
&& bookends.morning?.place_lat != null && bookends.morning?.place_lng != null
|
||||
? { lat: bookends.morning.place_lat, lng: bookends.morning.place_lng, name: bookends.morning.place_name } : null
|
||||
const evening = bookends && shouldDrawEveningLeg(bookends, day, lastStop, carrier.located)
|
||||
&& bookends.evening?.place_lat != null && bookends.evening?.place_lng != null
|
||||
? { lat: bookends.evening.place_lat, lng: bookends.evening.place_lng, name: bookends.evening.place_name } : null
|
||||
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
|
||||
}
|
||||
|
||||
/** Google-Maps directions URL over the day's bookended stops. */
|
||||
export function dayGoogleMapsUrl(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
carrier: DayCarrier = {},
|
||||
): string | null {
|
||||
return generateGoogleMapsUrl(
|
||||
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier),
|
||||
) || null
|
||||
}
|
||||
|
||||
/** The same stops handed to CoMaps for offline navigation, in the day's travel mode (#1904). */
|
||||
export function dayCoMapsUrl(
|
||||
day: Day,
|
||||
days: Day[],
|
||||
dayAssignments: Assignment[],
|
||||
accommodations: Accommodation[],
|
||||
bookendFromAccommodation: boolean,
|
||||
profile: RouteProfileKey,
|
||||
carrier: DayCarrier = {},
|
||||
): string | null {
|
||||
return generateCoMapsUrl(
|
||||
dayExportStops(day, days, dayAssignments, accommodations, bookendFromAccommodation, carrier),
|
||||
profile,
|
||||
) || null
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useRouteModeOptions } from '../../../../components/Planner/routeModes'
|
||||
import { getDayBookendHotels } from '../../../../utils/dayOrder'
|
||||
import { dayWeatherAnchor } from '../../../../utils/dayWeather'
|
||||
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEndpointOnDay, isCarrierTransport } from '../../../../utils/dayMerge'
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../lib/dayRoute'
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
|
||||
import { buildTransitLeg, buildTransitNameIndex, type TransitLeg } from '../../../../components/Planner/transitLeg'
|
||||
import {
|
||||
buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSi
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import { getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/dayMerge'
|
||||
import { splitReservationDateTime } from '../../../../utils/formatters'
|
||||
import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute'
|
||||
import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../../../../components/Planner/dayRoute'
|
||||
import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
|
||||
import { BlurredCode } from '../../../../components/shared/BookingCode'
|
||||
import { splitNoteTime } from '../lib/dayNotes'
|
||||
|
||||
Reference in New Issue
Block a user