Files
TREK/client/src/utils/dayOrder.ts
T
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00

135 lines
7.9 KiB
TypeScript

import { getDayBookendHotels, getDayOrder, hotelIsTheStop, isDayInAccommodationRange } from '@trek/shared'
import type { Day, Accommodation, RouteAnchors } from '../types'
import { parseTimeToMinutes } from './dayMerge'
import { haversineKm, withinDayTripRange } from './geo'
/**
* Set on an edge waypoint that is the endpoint of a booking which CARRIES you out of
* the day's geography (see `isCarrierTransport`): 'departure' = you left from this
* point, 'arrival' = you were set down at it. A hire car — a vehicle you keep driving —
* leaves this unset, so its pickup/drop-off points keep the hotel legs they have always
* drawn. Undefined always means "no opinion", which is what keeps every existing caller
* and the activity-free transfer day behaving exactly as before.
*/
export type CarrierEdge = 'departure' | 'arrival' | null
// Which stay a day wakes up in and which one it sleeps in is read in @trek/shared, so every
// reader of a day's nights makes the same choice. Days keeps importing it from here.
export { getDayBookendHotels, getDayOrder, isDayInAccommodationRange }
type EdgeStop = { isPlace: boolean; time?: string | null; carrierEdge?: CarrierEdge; lat?: number; lng?: number }
/**
* The #2009 default on a stay that records no check-in/check-out time: the loop closes,
* because a day out of a hotel comes back to it. It is a guess about a day nobody timed,
* and two things disprove it (#2157). A carrier on the day means you changed geography by
* air/rail/sea, so the hotel is one end of the day and not both. And an edge stop past
* day-trip range is where you travelled from or to, not somewhere the hotel sent you out
* to: the reported 500 km drive home is as much a disproof as the 2400 km flight, which
* is why the radius is a day's driving and not the router's reachability limit.
*/
const noTimeLoopHolds = (hotel: Accommodation, stop?: EdgeStop, dayHasCarrier?: boolean): boolean => {
if (dayHasCarrier) return false
if (stop?.lat == null || stop.lng == null || hotel.place_lat == null || hotel.place_lng == null) return true
return withinDayTripRange({ lat: hotel.place_lat, lng: hotel.place_lng }, { lat: stop.lat, lng: stop.lng })
}
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
// and into another — instead runs from the morning hotel to the evening one.
//
// `stops` are the located stops the optimizer is about to reorder, and they decide the same way the
// drawn bookend legs do: an anchor the map would not draw a leg to is not an anchor the optimizer
// gets to pin the day on either, or it sorts a "Home" 500 km out into the middle of the day (#2157).
// Since the order is what the optimizer is still deciding, no stop is the day's edge yet, so the one
// farthest from the hotel stands in: a day reaching past what the hotel could send you out to and
// back from is not a day that hotel bookends. Passing no stops means no evidence, and the anchors
// stay where they have always been.
export const getAccommodationAnchors = (
day: Day,
days: Day[],
accommodations: Accommodation[],
stops?: { lat: number; lng: number }[],
dayHasCarrier?: boolean,
): RouteAnchors => {
const bookends = getDayBookendHotels(day, days, accommodations)
const { morning, evening } = bookends
if (!morning || !evening) return {}
const at = (h: Accommodation) => ({ lat: h.place_lat as number, lng: h.place_lng as number })
// The optimizer only ever moves untimed stops, so the stand-in carries no time.
const farthestFrom = (h: Accommodation): EdgeStop | undefined => {
if (!stops?.length) return undefined
const from = at(h)
const far = stops.reduce((a, b) => (haversineKm(from, b) > haversineKm(from, a) ? b : a), stops[0])
return { isPlace: true, time: null, lat: far.lat, lng: far.lng }
}
const hasStops = !!stops?.length
return {
start: !hasStops || shouldDrawMorningLeg(bookends, day, farthestFrom(morning), dayHasCarrier) ? at(morning) : undefined,
end: !hasStops || shouldDrawEveningLeg(bookends, day, farthestFrom(evening), dayHasCarrier) ? at(evening) : undefined,
}
}
// Whether to draw the morning hotel → first-stop leg. It is a real drive when you slept in the
// morning hotel (a normal home-base day). On that hotel's check-in day you were traveling TO the
// hotel, so the leg is drawn only when the first stop is a PLACE provably timed at/after check-in
// (you dropped your bags first). A place before check-in (an airport you reach first, #1465), a
// transport arrival (you flew in, weren't at the hotel yet, #1321), or an un-timed place ("Home"
// before driving out, #1597) all mean no leg — mirroring the evening rule below. With no
// check-in time recorded the loop closes by default (#2009), except on a day a carrier
// takes you there or towards an edge place too far out to be a day trip (#2157).
export const shouldDrawMorningLeg = (
bookends: { morning?: Accommodation; morningIsSleptHere?: boolean },
day: Day,
firstStop?: EdgeStop,
dayHasCarrier?: boolean,
): boolean => {
if (hotelIsTheStop(bookends.morning, firstStop)) return false
// You landed here. Whatever hotel the day belongs to, nobody drove out of it to the
// airport they arrived at — so there is no morning leg, not even on a night you
// provably slept in that hotel (#2133).
if (firstStop?.carrierEdge === 'arrival') return false
if (bookends.morningIsSleptHere) return true
const m = bookends.morning
if (!m || m.start_day_id !== day.id || !firstStop?.isPlace) return false
const checkIn = parseTimeToMinutes(m.check_in)
// No check-in time on the stay means there is no bar to clear, and "no
// information" was being read as "proof against", which silently opened the
// loop at the start of every arrival day, since the hotel picker leaves the
// time blank by default (#2009). That loop is still only a guess, so an arrival
// in the wide sense (a carrier, or a first stop too far out to be a day trip)
// puts the hotel at the day's END instead (#2157). With a time set, #1465 still
// holds: an earlier stop is a place you reached before the hotel and draws no leg.
if (checkIn == null) return noTimeLoopHolds(m, firstStop, dayHasCarrier)
const stop = parseTimeToMinutes(firstStop.time)
return stop != null && stop >= checkIn
}
// Mirror of shouldDrawMorningLeg for the last-stop → hotel evening leg. It is a real drive when
// you sleep in the evening hotel tonight. On that hotel's check-out day you have already left, so
// the return leg is NOT the default — drawn only when the last stop is a PLACE timed at/before
// check-out (a swing back before checking out). A later stop (heading home, #1465), an un-timed
// stop, or an evening transport departure (S7) all mean no return leg.
export const shouldDrawEveningLeg = (
bookends: { evening?: Accommodation; eveningIsOvernight?: boolean },
day: Day,
lastStop?: EdgeStop,
dayHasCarrier?: boolean,
): boolean => {
if (hotelIsTheStop(bookends.evening, lastStop)) return false
// Mirror: you took off from here, so no drive leads from it back to tonight's hotel —
// the reported "flight starting airport connected to the accommodation" (#2133).
if (lastStop?.carrierEdge === 'departure') return false
if (bookends.eveningIsOvernight) return true
const e = bookends.evening
if (!e || e.end_day_id !== day.id || !lastStop?.isPlace) return false
const checkOut = parseTimeToMinutes(e.check_out)
// Mirror of the morning rule: with no check-out time recorded there is nothing
// to have missed, so the return leg is drawn and the loop closes (#2009), unless
// the day disproves it (#2157). A carrier, or a last stop too far out to be a day
// trip, is a departure, and the hotel is then where the day STARTS.
if (checkOut == null) return noTimeLoopHolds(e, lastStop, dayHasCarrier)
const stop = parseTimeToMinutes(lastStop.time)
return stop != null && stop <= checkOut
}