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])
})
})