diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 187655c36..0541d3c9b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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). diff --git a/client/src/components/Planner/dayRoute.test.ts b/client/src/components/Planner/dayRoute.test.ts new file mode 100644 index 000000000..ff3a38fe3 --- /dev/null +++ b/client/src/components/Planner/dayRoute.test.ts @@ -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(), ['x', 'y'])).toEqual(['x', 'y']); + }); +}); diff --git a/client/src/components/Planner/dayRoute.ts b/client/src/components/Planner/dayRoute.ts new file mode 100644 index 000000000..c8d459267 --- /dev/null +++ b/client/src/components/Planner/dayRoute.ts @@ -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(); + 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(length: number, locked: Map, 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 + ); +} diff --git a/client/src/mobile/screens/trip/lib/dayRoute.test.ts b/client/src/mobile/screens/trip/lib/dayRoute.test.ts deleted file mode 100644 index 2b046f096..000000000 --- a/client/src/mobile/screens/trip/lib/dayRoute.test.ts +++ /dev/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') - }) -}) diff --git a/client/src/mobile/screens/trip/lib/dayRoute.ts b/client/src/mobile/screens/trip/lib/dayRoute.ts deleted file mode 100644 index faba4eaed..000000000 --- a/client/src/mobile/screens/trip/lib/dayRoute.ts +++ /dev/null @@ -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() - 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 -} diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 432381cec..9ca690280 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -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, diff --git a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx index f745d2ba2..a4c2aa448 100644 --- a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx @@ -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'