mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else planned, falls back to a drive from the first hotel to the second (#1297). A flight, train, ferry or coach booked on that day is the move itself, but one saved without its stations leaves no waypoint behind, so the day map, the trip overview and the phone plan all drew the full road between the two hotels, and the phone showed it above and below the flight (#2476). With its stations the day was already right. - The fallback drive between the two hotels is no longer drawn when any such booking is on the day, located or not. A day without a booking keeps it. No line beats a wrong one. - The phone plan takes the evening hotel leg from the last drive that reaches the hotel, not the first, so an earlier drive to the same spot no longer stands in for the one that closes the day. - Google Maps and CoMaps exports of such a day, which could only ever describe that same drive, return nothing, and on the desktop and the phone the two buttons are left out whenever fewer than two stops remain, instead of doing nothing on a tap. A hotel stop planned by hand ahead of the flight already sorts behind it through the ride seating of #2474; a test now pins that for the days view as well.
This commit is contained in:
@@ -145,4 +145,58 @@ describe('buildDayRouteRuns', () => {
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
describe('a moving day from one stay to the next (#2476)', () => {
|
||||
// Day 3 checks out of a hotel in Munich and into one in Hamburg, with a flight
|
||||
// in between. The booked Hamburg night wrote its own (hidden) stop onto day 3.
|
||||
const HOTEL_A = { lat: 48.137, lng: 11.575 }
|
||||
const HOTEL_B = { lat: 53.551, lng: 9.993 }
|
||||
const MUC = { lat: 48.353, lng: 11.786 }
|
||||
const HAM = { lat: 53.63, lng: 9.988 }
|
||||
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
|
||||
const stays = [
|
||||
{ id: 1, trip_id: 1, place_lat: HOTEL_A.lat, place_lng: HOTEL_A.lng, start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00' },
|
||||
{ id: 2, trip_id: 1, place_lat: HOTEL_B.lat, place_lng: HOTEL_B.lng, start_day_id: 3, end_day_id: 5, check_in: '15:00', check_out: '11:00' },
|
||||
] as unknown as Accommodation[]
|
||||
const bookedStop = buildAssignment({ day_id: 3, order_index: 0, accommodation_id: 2, place: buildPlace({ ...HOTEL_B }) })
|
||||
const flight = (located: boolean) => ({
|
||||
id: 7, trip_id: 1, type: 'flight', title: 'LH 2078', status: 'confirmed', day_id: 3, end_day_id: 3,
|
||||
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20',
|
||||
endpoints: located
|
||||
? [{ role: 'from', sequence: 0, name: 'MUC', ...MUC }, { role: 'to', sequence: 1, name: 'HAM', ...HAM }]
|
||||
: [],
|
||||
}) as unknown as Reservation
|
||||
const movingDay = (reservations: Reservation[], dayStops = [bookedStop]) =>
|
||||
buildDayRouteRuns(3, inputs({
|
||||
days, assignments: { '3': dayStops }, reservations, accommodations: stays, optimizeFromAccommodation: true,
|
||||
})).map(run => run.map(p => [p.lat, p.lng]))
|
||||
|
||||
it('FE-MAP-DRP-011: a flight saved without airports draws no drive from one hotel to the other', () => {
|
||||
// Nothing located is left on the day, and the flight is still the move: a road
|
||||
// from Munich to Hamburg is exactly the trip that did not happen.
|
||||
expect(movingDay([flight(false)])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-012: with its airports the flight splits the day at them, as reported', () => {
|
||||
expect(movingDay([flight(true)])).toEqual([
|
||||
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
|
||||
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-013: without any booking the move is still the drive between the two hotels (#1297)', () => {
|
||||
expect(movingDay([])).toEqual([[[HOTEL_A.lat, HOTEL_A.lng], [HOTEL_B.lat, HOTEL_B.lng]]])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-014: a hotel stop planned by hand ahead of the flight is reached after landing', () => {
|
||||
// The traveller put the Hamburg hotel on the day before booking it, so the stop
|
||||
// stays visible and carries no time. The flight still seats itself in front of
|
||||
// the place it lands next to, and no road runs from Munich to Hamburg.
|
||||
const handPlaced = at(HOTEL_B.lat, HOTEL_B.lng, 0, { day_id: 3 })
|
||||
expect(movingDay([flight(true)], [handPlaced])).toEqual([
|
||||
[[HOTEL_A.lat, HOTEL_A.lng], [MUC.lat, MUC.lng]],
|
||||
[[HAM.lat, HAM.lng], [HOTEL_B.lat, HOTEL_B.lng]],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +170,12 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
|
||||
// Transfer day with no activities: you check out of one accommodation and into
|
||||
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||
// Not when a flight, train, ferry or coach is booked on the day, located or not:
|
||||
// that booking IS the move, and the road from one hotel to the other is exactly
|
||||
// the stretch nobody drove (#2476). One saved without its stations leaves no
|
||||
// waypoint behind, so the gates above never see it; no line beats a wrong one.
|
||||
const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r))
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
import type { Accommodation, AssignmentsMap, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>()
|
||||
@@ -212,4 +212,24 @@ describe('tripRouteGeometry', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-012: the overview draws no drive between two stays on the day a flight connects them (#2476)', () => {
|
||||
// Day 2 moves from a Munich hotel to a Hamburg one. The overview reads the same
|
||||
// day plan as the map, so the flight saved without airports keeps the road off it.
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const accommodations = [
|
||||
{ id: 1, trip_id: 1, place_lat: 48.137, place_lng: 11.575, start_day_id: 1, end_day_id: 2 },
|
||||
{ id: 2, trip_id: 1, place_lat: 53.551, place_lng: 9.993, start_day_id: 2, end_day_id: 3 },
|
||||
] as unknown as Accommodation[]
|
||||
const flight = {
|
||||
id: 80, trip_id: 1, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
|
||||
reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', endpoints: [],
|
||||
} as unknown as Reservation
|
||||
const moving = { days, assignments: {}, accommodations, optimizeFromAccommodation: true }
|
||||
|
||||
expect(planTripRoute({ ...moving, reservations: [flight] }, 'driving')).toEqual([])
|
||||
// Without the booking the day is a drive from one hotel to the other, as before (#1297).
|
||||
const byRoad = planTripRoute({ ...moving, reservations: [] }, 'driving')
|
||||
expect(byRoad.map(d => d.day.id)).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3934,6 +3934,52 @@ describe('DayPlanSidebar', () => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('a moving day with a flight between the two stays (#2476)', () => {
|
||||
// Day 2 checks out of a Munich hotel and into a Hamburg one, and nothing else is
|
||||
// planned on it. The booked night's own stop never reaches the list.
|
||||
const movingDays = [
|
||||
buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2026-11-04', title: 'Day 2' }),
|
||||
buildDay({ id: 12, date: '2026-11-05', title: 'Day 3' }),
|
||||
]
|
||||
const stays: Accommodation[] = [
|
||||
{ id: 1, trip_id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.137, place_lng: 11.575, place_name: 'Hotel A' },
|
||||
{ id: 2, trip_id: 1, start_day_id: 11, end_day_id: 12, place_lat: 53.551, place_lng: 9.993, place_name: 'Hotel B' },
|
||||
]
|
||||
const airport = (role: 'from' | 'to', name: string, lat: number, lng: number) =>
|
||||
({ role, sequence: role === 'from' ? 0 : 1, name, code: null, lat, lng, timezone: null, local_date: null, local_time: null })
|
||||
const flight = (located: boolean) => buildReservation({
|
||||
id: 77, type: 'flight', title: 'LH 2078', day_id: 11, end_day_id: 11,
|
||||
reservation_time: '2026-11-04T15:15:00', reservation_end_time: '2026-11-04T17:20:00',
|
||||
endpoints: located ? [airport('from', 'MUC', 48.353, 11.786), airport('to', 'HAM', 53.63, 9.988)] : [],
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-222: offers no Google Maps or CoMaps route from one hotel to the other', () => {
|
||||
for (const located of [true, false]) {
|
||||
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: movingDays, accommodations: stays, reservations: [flight(located)], selectedDayId: 11,
|
||||
})} />)
|
||||
// The route itself can still be shown; only the hand-offs, which could only
|
||||
// ever describe a drive from Munich to Hamburg, are gone.
|
||||
expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open in CoMaps' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-223: the hotel legs run to the departure airport and from the arrival airport', async () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: movingDays, accommodations: stays, reservations: [flight(true)], selectedDayId: 11, routeShown: true,
|
||||
})} />)
|
||||
await waitFor(() => expect(vi.mocked(calculateRouteWithLegs)).toHaveBeenCalledTimes(2))
|
||||
const pairs = vi.mocked(calculateRouteWithLegs).mock.calls.map(c => c[0])
|
||||
expect(pairs).toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 48.353, lng: 11.786 }])
|
||||
expect(pairs).toContainEqual([{ lat: 53.63, lng: 9.988 }, { lat: 53.551, lng: 9.993 }])
|
||||
expect(pairs).not.toContainEqual([{ lat: 48.137, lng: 11.575 }, { lat: 53.551, lng: 9.993 }])
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { daysApi } = await import('../../api/client')
|
||||
|
||||
@@ -1627,7 +1627,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// useRouteCalculation) even with zero places. Mirror that exact gate here — two
|
||||
// distinct bookend hotels you actually slept in / sleep in tonight — so the route
|
||||
// tools appear when you click the day (#1297). A same-hotel rest day or a plain
|
||||
// arrival/departure day has morning === evening and stays excluded.
|
||||
// arrival/departure day has morning === evening and stays excluded. With a flight
|
||||
// or train booked on it the map drops that leg and keeps only the drives to and
|
||||
// from located stations, and the exports below have nothing to hand over (#2476).
|
||||
const transferMorning = routeBookends?.morning
|
||||
const transferEvening = routeBookends?.evening
|
||||
const hasHotelTransfer = !!(
|
||||
@@ -1647,6 +1649,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
*/
|
||||
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 && (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && isCarrierTransport(i.data))) 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]
|
||||
@@ -1662,6 +1668,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
? { 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. A route needs two ends: with
|
||||
// fewer the hand-offs would open a lone pin or nothing at all (#2476).
|
||||
const exportStops = showRouteTools ? dayExportStops() : []
|
||||
// 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).
|
||||
@@ -2832,7 +2842,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend #1330 — oder Hotel-zu-Hotel-Transfertag ohne Orte #1297) */}
|
||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
|
||||
{showRouteTools && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button type="button"
|
||||
@@ -2865,10 +2875,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{!narrowPanel && t('dayplan.route')}
|
||||
</button>
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
|
||||
{exportStops.length >= 2 && <Tooltip label={t('planner.openGoogleMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(dayExportStops())
|
||||
const url = generateGoogleMapsUrl(exportStops)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
@@ -2881,14 +2891,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
>
|
||||
<GoogleMapsIcon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
{/* The same day, handed to CoMaps for offline navigation (#1904). The
|
||||
day's own travel mode rides along, so the route it builds walks
|
||||
when the plan walks. */}
|
||||
<Tooltip label={t('planner.openCoMaps')} placement="top">
|
||||
{exportStops.length >= 2 && <Tooltip label={t('planner.openCoMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
|
||||
const url = generateCoMapsUrl(exportStops, day.default_transport_mode ?? routeProfile)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openCoMaps')}
|
||||
@@ -2901,7 +2911,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
>
|
||||
<Compass size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
{/* Icon-only, like the two map hand-offs beside it (#1981). It
|
||||
was the one button here carrying a label with no room for
|
||||
it: `flex: 1` alongside `padding: '6px 0'` meant the text
|
||||
|
||||
Reference in New Issue
Block a user