fix(map): drop the hotel leg to/from a transport endpoint on arrival days (#1321)

On the first day of a trip the morning hotel is only a check-in fallback
— you arrive from home, you didn't sleep there — so bookending the route
from that hotel to the flight/train departure point drew a phantom
hotel → departure leg, both on the map and in the day sidebar. The same
backwards leg showed up on a multi-day transport's arrival day, and its
mirror departure → hotel on an evening departure.

getDayBookendHotels now also reports whether the morning hotel is one you
actually slept in and whether you sleep in the evening hotel tonight. The
map and sidebar only draw a hotel↔transport bookend when that holds; a
hotel↔place leg is always kept, so the home-base loop and onward-travel
legs are unaffected. The optimizer keeps using the hotel values as before.
This commit is contained in:
Maurice
2026-06-26 16:32:06 +02:00
parent 92e3ebb4d5
commit daa4e00ab3
5 changed files with 150 additions and 13 deletions
@@ -411,25 +411,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// waypoint of the day (morning) and from the last one back to it (evening). Only when
// the "optimize from accommodation" setting is on and the day has a hotel.
const day = days.find(d => d.id === selectedDayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, days, accommodations)
: null
const startHotel = bookends?.morning
const endHotel = bookends?.evening
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
// legs connect even when the day starts or ends with a booking rather than a place.
const wayPts: { lat: number; lng: number }[] = []
// legs connect even when the day starts or ends with a booking rather than a place. Track
// whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
// arrival the check-in hotel never drove to the departure airport (#1321).
const wayPts: { lat: number; lng: number; isPlace: boolean }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
}
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
const wantTop = !!(startHotel && firstWay)
const wantBottom = !!(endHotel && lastWay)
const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
+21 -3
View File
@@ -105,8 +105,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {}
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
@@ -114,7 +115,24 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel))
// Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
// if the first stop is a place, or if you actually slept in that hotel last night;
// on a day-1 arrival the morning hotel is just a check-in fallback and the first
// waypoint is the transport's departure point, so [hotel → departure] is dropped
// (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
// in the evening and don't sleep in that hotel tonight.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
const runsWithHotel = withHotelBookends(
runs,
flatPts[0],
flatPts[flatPts.length - 1],
drawMorning ? hotelPt(bookends?.morning) : null,
drawEvening ? hotelPt(bookends?.evening) : null,
)
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
+36
View File
@@ -117,4 +117,40 @@ describe('getDayBookendHotels', () => {
const h = hotel({ place_lat: null, place_lng: null })
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
})
it('flags an arrival/check-in day as not slept-here in the morning (#1321)', () => {
// Day 1: you arrive from home and check in tonight, so the morning hotel is only a
// check-in fallback — no hotel → departure leg should be drawn.
const into = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 3, place_lng: 4 })
const r = getDayBookendHotels(days[0], days, [into])
expect(r.morning).toBe(into)
expect(r.morningIsSleptHere).toBe(false)
expect(r.eveningIsOvernight).toBe(true)
// The optimizer anchor must stay a loop on the check-in day (values unchanged).
expect(getAccommodationAnchors(days[0], days, [into])).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
})
it('flags a mid-stay day as slept-here and overnight', () => {
const h = hotel({ start_day_id: 10, end_day_id: 30 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
it('an evening departure with no replacement check-in is not overnight (S7 mirror)', () => {
// You woke up here but check out today and board an evening transport — you do not
// sleep here tonight, so the last-stop → hotel leg must be droppable.
const h = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const r = getDayBookendHotels(days[1], days, [h])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(false)
})
it('flags a transfer day as slept-here in the morning and overnight in the evening', () => {
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
const r = getDayBookendHotels(days[1], days, [out, into])
expect(r.morningIsSleptHere).toBe(true)
expect(r.eveningIsOvernight).toBe(true)
})
})
+8 -1
View File
@@ -12,7 +12,7 @@ export const getDayBookendHotels = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): { morning?: Accommodation; evening?: Accommodation } => {
): { morning?: Accommodation; evening?: Accommodation; morningIsSleptHere?: boolean; eveningIsOvernight?: boolean } => {
const inRange = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
@@ -30,6 +30,13 @@ export const getDayBookendHotels = (
return {
morning: sleptHere ?? checkIn ?? inRange[0],
evening: checkIn ?? sleptHere ?? inRange[0],
// Provenance for the drawing consumers (map + sidebar). A hotel↔transport bookend
// is only real when you actually used the hotel: morningIsSleptHere is true only
// when you woke up there (not a check-in fallback on an arrival day), and
// eveningIsOvernight is true only when you sleep there tonight (you check in today,
// or an earlier stay continues past today). The optimizer keeps using the values.
morningIsSleptHere: sleptHere != null,
eveningIsOvernight: checkIn != null || (sleptHere != null && orderOf(sleptHere.end_day_id) > dayOrd),
}
}
@@ -251,6 +251,77 @@ describe('useRouteCalculation', () => {
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-014: #1321 day-1 arrival draws no check-in-hotel → departure leg', async () => {
// Day 1 = arrival from home: a flight (departure → arrival airport) then two activities,
// checking into a hotel tonight. The morning hotel is only a check-in fallback, so the
// hotel must NOT be bookended to the flight's departure point; the evening leg stays.
const dep = { lat: 50.03, lng: 8.57 }; // home/departure airport
const arr = { lat: 41.30, lng: 2.08 }; // destination airport
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const flight = {
id: 100, type: 'flight', day_id: 1, end_day_id: 1, day_plan_position: 0,
endpoints: [
{ role: 'from', lat: dep.lat, lng: dep.lng },
{ role: 'to', lat: arr.lat, lng: arr.lng },
],
};
const a1 = buildAssignment({ day_id: 1, order_index: 1, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 2, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
// A single stable store reference (like buildMockStore) so selectedDayAssignments
// keeps its identity across renders and the effect doesn't loop.
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [flight],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
// The spurious morning bookend [hotel → departure airport] must be gone.
expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${dep.lat},${dep.lng}`]);
// The route starts the day's run at the arrival airport, not the hotel.
expect(result.current.route?.[0]?.[0]).toEqual([arr.lat, arr.lng]);
// The evening leg [last activity → hotel] is still drawn.
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-015: day-1 with no transport keeps the hotel → first-activity leg', async () => {
// Guard against over-suppression: with no arrival transport, the check-in day is a
// home-base loop and the hotel → first-stop leg must remain.
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB });
const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
reservations: [],
days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
} as any);
const { result } = renderHook(() =>
useRouteCalculation(store, 1, true, 'driving', accommodations as any)
);
await act(async () => {});
const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
expect(legs).toContainEqual([`${hotel.lat},${hotel.lng}`, `${actA.lat},${actA.lng}`]);
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
const store = buildMockStore({});
const { result } = renderHook(() =>