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:
Maurice
2026-09-23 16:27:41 +02:00
parent ae80ad266d
commit 8d78c988c3
14 changed files with 335 additions and 27 deletions
@@ -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]],
])
})
})
})
+6 -1
View File
@@ -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