fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)

On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
This commit is contained in:
Maurice
2026-09-25 19:46:29 +02:00
parent 5d4178d067
commit 62f8b0deee
8 changed files with 314 additions and 51 deletions
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns } from './dayRoutePlan'
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
@@ -142,6 +142,30 @@ describe('buildDayRouteRuns', () => {
expect(runs[0].map(p => p.lat)).toEqual([53.5503, 53.5465])
})
it('FE-MAP-DRP-015: the hotel points say which end of the day they are, a stop on the same spot does not (#2501)', () => {
const hotel = { id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
const runs = buildDayRouteRuns(2, inputs({
days: [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 }), buildDay({ id: 3, day_number: 3 })],
assignments: { '2': [at(48.86, 2.35, 0), at(48.80, 2.30, 1), at(48.88, 2.36, 2)] },
accommodations: [hotel],
optimizeFromAccommodation: true,
}))
const legs = runs.flatMap(run => run.slice(1).map((p, i) => hotelBookendOf(run[i], p) ?? null))
// Out of the hotel, three stops (one of them on the hotel's own spot), back to it.
expect(legs).toEqual(['morning', null, null, 'evening'])
})
it('FE-MAP-DRP-016: the one drive of a moving day without stops is its morning leg (#2476)', () => {
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
const stays = [
{ 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 [run] = buildDayRouteRuns(2, inputs({ days, accommodations: stays, optimizeFromAccommodation: true }))
expect(run.map(p => p.hotel)).toEqual(['morning', 'evening'])
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
})
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([])
})
+21 -7
View File
@@ -13,6 +13,19 @@ export interface DayRoutePoint {
isPlace: boolean
leg_transport_mode?: string | null
incoming_leg_transport_mode?: string | null
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
hotel?: 'morning' | 'evening'
}
/**
* Which accommodation bookend the leg between two neighbouring run points is, if any.
* A day that is nothing but the drive from one stay to the next counts as its morning
* leg, so it shows once at the top rather than at both ends (#2476).
*/
export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined {
if (from.hotel === 'morning') return 'morning'
if (to.hotel === 'evening') return 'evening'
return undefined
}
/** Everything the plan depends on, passed in rather than read from the store, so the
@@ -131,9 +144,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override.
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
// carries its own override. It says which end of the day it is, so the legs routed
// from it can be told apart from a stop planned on the hotel's own spot (#2501).
const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend.
@@ -154,8 +168,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
const morningHotel = hotelPt(bookends?.morning, 'morning')
const eveningHotel = hotelPt(bookends?.evening, 'evening')
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
@@ -176,8 +190,8 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
// 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)
const m = hotelPt(bookends?.morning, 'morning')
const e = hotelPt(bookends?.evening, 'evening')
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}