Files
TREK/client/src/utils/dayMerge.ts
T
Maurice 3b933470d3 fix(planner): a booking pinned to a stop on another day is shown at that stop only, not on two days
A booking linked to a stop has two days to go by, its own date and the
stop's. They part when the stop is moved to another day or the trip is
shifted with "keep bookings on their dates", and the plan then listed the
booking at the stop and again in the timeline of its own day. The stop now
wins wherever both are drawn: the desktop day plan and day detail, the
phone's plan tab and day sheet, and the road trip rail on both. The
booking's date is left as it is; a stop on one of the booking's own days
changes nothing, so a rental picked up at a stop still runs across its
other days.

Refs #2604
2026-10-12 20:42:30 +02:00

639 lines
28 KiB
TypeScript

import { chronoOrder } from '@trek/shared'
import { nightVisits, rideSeatAfter, sameDayRide, type CarrierBooking, type SeatItem } from '@trek/shared/roadtrip'
// `orderedEndpoints` is the geometry order's single source of truth, in the module
// that documents the multi-leg model. Sorting endpoints a second time here is how
// the two drift.
import { orderedEndpoints } from './flightLegs'
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'])
export interface MergedItem {
type: 'place' | 'note' | 'transport'
sortKey: number
data: any
}
export function parseTimeToMinutes(time?: string | null): number | null {
if (!time) return null
if (time.includes('T')) {
const [h, m] = time.split('T')[1].split(':').map(Number)
return h * 60 + m
}
const parts = time.split(':').map(Number)
if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1]
return null
}
export function getSpanPhase(
r: { day_id?: number | null; end_day_id?: number | null },
dayId: number
): 'single' | 'start' | 'middle' | 'end' {
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (!startDayId || startDayId === endDayId) return 'single'
if (dayId === startDayId) return 'start'
if (dayId === endDayId) return 'end'
return 'middle'
}
/**
* Booking types that carry no information on the days between their start and end and
* therefore drop out of the day timeline there. A parked car just stands in the garage:
* you only need to know when you hand it over and when you collect it, so the days in
* between show nothing at all, not even a badge in the day header (#1937).
*
* A car rental is deliberately NOT in this set: its middle days are not dropped but
* moved into the day header, which is what people expect from a vehicle they are
* driving around. Kept as a set so another such type can join without editing every
* render site again.
*/
const MIDDLE_DAY_HIDDEN_TYPES = new Set(['parking'])
export function hidesOnMiddleDay(
r: { type?: string | null; day_id?: number | null; end_day_id?: number | null },
dayId: number
): boolean {
return !!r.type && MIDDLE_DAY_HIDDEN_TYPES.has(r.type) && getSpanPhase(r, dayId) === 'middle'
}
/**
* Bookings you ride and then leave behind: the vehicle carries you out of the day's
* geography and keeps going without you. Their endpoints are therefore not the ends of
* a drive — you did not drive to tonight's hotel from the airport you took off from, and
* you did not drive to the airport you landed at from this morning's hotel (#2133).
*
* A hire car, a parked car or a hired bike is the exact opposite and must stay out of
* this set: you collect the vehicle and keep driving it, so a multi-day rental's pickup
* point IS where a drive to the hotel starts, and its drop-off point IS where a drive
* from the hotel ends (both pinned by the car-rental span rules above).
*
* `taxi`, `transit` and `transport_other` are left out on purpose: they are same-day
* hops, so both their endpoints land on the day anyway and the distinction never fires.
* Keeping them out means this set cannot change any behaviour they have today.
*/
const CARRIER_TRANSPORT_TYPES = new Set(['flight', 'train', 'ferry', 'cruise', 'bus'])
export function isCarrierTransport(r: { type?: string | null }): boolean {
return !!r.type && CARRIER_TRANSPORT_TYPES.has(r.type)
}
/**
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
* A car rental (or any reservation whose span covers several days) is only routed to on its
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
* route to those points. Single-day transports contribute both endpoints.
*/
export function getTransportRouteEndpoints(
r: any,
dayId: number
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
// A leg of a multi-leg booking spans its OWN pair of airports, endpoints[i] and
// endpoints[i+1] — not the booking's outermost two. Handing back the booking's
// from/to for every leg put a stop between two legs on a road route from the
// arrival airport back to the departure one (#2071).
//
// Only when the endpoints line up one-per-stop with the legs. An endpoint that
// failed to geocode never reaches the row, and a short list would silently map
// every earlier leg to the wrong pair — so an ambiguous booking keeps the role
// lookup it has always used.
const ordered = orderedEndpoints(r)
const legIdx: number | null =
r.__leg && ordered.length === r.__leg.total + 1 ? (r.__leg.index as number) : null
const point = (e: { lat?: number | null; lng?: number | null } | undefined): { lat: number; lng: number } | null =>
e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
if (legIdx != null) return point(ordered[role === 'from' ? legIdx : legIdx + 1])
return point((r.endpoints || []).find((x: { role?: string }) => x.role === role))
}
switch (getSpanPhase(r, dayId)) {
case 'start':
return { from: ep('from'), to: null }
case 'end':
return { from: null, to: ep('to') }
case 'middle':
return { from: null, to: null }
default:
return { from: ep('from'), to: ep('to') }
}
}
/**
* Whether this booking is a carrier that contributes a located route endpoint on the
* given day — the signal that the day's geography changes by riding something rather
* than driving, which the hotel-bookend rules need on days without recorded
* check-in/check-out times (#2157). The span rules above make an overnight carrier
* count only on the days it actually touches. A carrier saved without coordinates
* deliberately does not count: it contributes nothing to the drawn route, so treating
* it as proof of travel would flip hotel legs on data the map cannot see.
*/
export function hasCarrierEndpointOnDay(r: any, dayId: number): boolean {
if (!isCarrierTransport(r)) return false
const { from, to } = getTransportRouteEndpoints(r, dayId)
return from != null || to != null
}
export function getDisplayTimeForDay(
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
dayId: number
): string | null {
const phase = getSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
function parseMultiLegs(r: any): any[] | null {
if (r?.type !== 'flight' && r?.type !== 'train') return null
let meta = r.metadata
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
// Defensive: recover metadata that was accidentally double-encoded by an earlier
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
return null
}
/**
* Expand a multi-leg flight/train into one synthetic reservation per leg that
* touches `dayId`, each with its own day span + departure/arrival time so it
* slots into the timeline independently. A single-leg booking (or any other
* reservation) is returned untouched, so existing behaviour is unchanged.
*/
export function expandFlightLegsForDay(
r: any,
dayId: number,
getDayOrder: (id: number) => number,
days: Array<{ id: number; date?: string | null }>
): any[] {
const legs = parseMultiLegs(r)
if (!legs) return [r]
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
const thisOrder = getDayOrder(dayId)
const out: any[] = []
legs.forEach((leg, i) => {
const dep = leg.dep_day_id ?? r.day_id ?? null
const arr = leg.arr_day_id ?? dep
if (dep == null) return
const depOrder = getDayOrder(dep)
const arrOrder = getDayOrder(arr ?? dep)
if (!(thisOrder >= depOrder && thisOrder <= arrOrder)) return
const depDate = dateOf(dep)
const arrDate = dateOf(arr ?? dep)
out.push({
...r,
day_id: dep,
end_day_id: arr ?? dep,
reservation_time: leg.dep_time ? (depDate ? `${depDate}T${leg.dep_time}` : leg.dep_time) : null,
reservation_end_time: leg.arr_time ? (arrDate ? `${arrDate}T${leg.arr_time}` : leg.arr_time) : null,
// Each leg carries its OWN saved position (not the booking's) so items can be
// dropped between legs and persist; absent → falls back to time ordering.
day_positions: leg.day_positions || undefined,
day_plan_position: undefined,
__leg: {
index: i, total: legs.length,
from: leg.from ?? null, to: leg.to ?? null,
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
// Train legs carry their own per-leg detail; added only for trains so a
// flight's __leg object stays byte-identical to before.
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
},
})
})
return out
}
/** The day each stop of the trip sits on, by its assignment id, for {@link pinnedAway}. */
export function stopDayIds(assignments: Record<string, readonly { id: number }[] | undefined>): Map<number, number> {
const out = new Map<number, number>()
for (const [dayId, list] of Object.entries(assignments)) {
for (const a of list || []) out.set(a.id, Number(dayId))
}
return out
}
/**
* Whether a booking pinned to a stop is shown at that stop and not on its own day.
* Such a booking has two days to go by, its own date and the stop's, and they part
* when the stop is moved to another day or the trip's dates shift while bookings keep
* theirs. The stop wins then, so the booking is listed once instead of on two days,
* and its date stays as it is (#2604). A stop on one of the booking's own days changes
* nothing: a rental picked up at a stop still runs across the rest of its days.
*/
export function pinnedAway(
r: { assignment_id?: number | null; day_id?: number | null; end_day_id?: number | null },
stopDays: ReadonlyMap<number, number> | undefined,
dayOrder: (dayId: number) => number | null | undefined
): boolean {
if (!r.assignment_id || !stopDays || r.day_id == null) return false
const stopDay = stopDays.get(r.assignment_id)
if (stopDay == null) return false
const at = dayOrder(stopDay)
const first = dayOrder(r.day_id)
const last = dayOrder(r.end_day_id ?? r.day_id)
if (at == null || first == null || last == null) return false
return at < Math.min(first, last) || at > Math.max(first, last)
}
/** {@link pinnedAway} over the whole trip: its stops from `assignments`, its days in plan order. */
export function pinnedAwayIn(
assignments: Record<string, readonly { id: number }[] | undefined>,
days: Array<{ id: number; day_number?: number }>
): (r: { assignment_id?: number | null; day_id?: number | null; end_day_id?: number | null }) => boolean {
const stopDays = stopDayIds(assignments)
const order = (id: number): number | null => {
const d = days.find(x => x.id === id)
return d ? (d.day_number ?? days.indexOf(d)) : null
}
return r => pinnedAway(r, stopDays, order)
}
/**
* Filter reservations that are active transports for the given day, excluding assignment-linked ones.
* With `stopDays` a booking pinned to a stop on another day is left to that stop as well.
*/
export function getTransportForDay(opts: {
reservations: any[]
dayId: number
dayAssignmentIds: number[]
days: Array<{ id: number; day_number?: number; date?: string | null }>
stopDays?: ReadonlyMap<number, number>
}): any[] {
const { reservations, dayId, dayAssignmentIds, days, stopDays } = opts
const getDayOrder = (id: number): number => {
const d = days.find(x => x.id === id)
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
}
const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => {
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
if (pinnedAway(r, stopDays, getDayOrder)) return false
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (startDayId == null) return false
if (endDayId !== startDayId) {
const startOrder = getDayOrder(startDayId)
const endOrder = getDayOrder(endDayId)
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
}
return startDayId === dayId
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
}
/**
* Every booking pinned to one day assignment. A stop can carry several (the parking
* pass and the tickets for the same zoo), and the exclusion above keeps all of them
* out of the timeline, so the place row is the only surface that can show them, and
* a `find()` there dropped the rest without trace (#2201).
*
* Earliest first, untimed last, id as the tiebreaker: the store's array order is
* newest-first for locally created bookings and load order otherwise, which would
* shuffle the same two bookings between a reload and a live update.
*/
export function getAssignmentReservations<T extends {
id: number
assignment_id?: number | null
reservation_time?: string | null
}>(reservations: T[], assignmentId: number | null | undefined): T[] {
if (!assignmentId) return []
return reservations
.filter(r => r.assignment_id === assignmentId)
.sort((a, b) => {
const at = a.reservation_time || ''
const bt = b.reservation_time || ''
if (at !== bt) {
if (!at) return 1
if (!bt) return -1
return at < bt ? -1 : 1
}
return a.id - b.id
})
}
/**
* The bookings that belong to a place: the ones pinned to the open stop, and the
* stay booked for it when the place is a hotel (#2363). Without the second, a
* hotel opened from its pill in the day head showed the place but no route to its
* booking. Pinned ones keep their order and come first.
*/
export function getPlaceBookings<T extends {
id: number
assignment_id?: number | null
reservation_time?: string | null
accommodation_place_id?: number | null
}>(reservations: T[], assignmentId: number | null | undefined, placeId: number | null | undefined): T[] {
const pinned = getAssignmentReservations(reservations, assignmentId)
if (placeId == null) return pinned
const pinnedIds = new Set(pinned.map(r => r.id))
const stays = reservations.filter(r => r.accommodation_place_id === placeId && !pinnedIds.has(r.id))
return [...pinned, ...stays]
}
/**
* Order items chronologically: anything with a time (a place's place_time, a
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
* time inherits the time of the timed item before it, so untimed items stay where
* they were manually placed. Stable on the incoming order for ties.
*
* The rule itself is `chronoOrder` in @trek/shared, the same one the server uses when
* a start time is saved. The server feeds it the day's stops only, though. An untimed
* place behind a timed note or booking inherits that item's time here and the previous
* stop's time there, so on such a day the stored order and this one can differ.
*/
function applyChronoOrder(
items: MergedItem[],
dayId: number,
getDisplayTime: (r: any, dayId: number) => string | null
): MergedItem[] {
return chronoOrder(items, it => {
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
})
}
/**
* A day order with a dragged booking or note at `index`, moved to the nearest slot the
* list draws it in. A row with a time is drawn by it, and the untimed rows behind it
* take that time too. Dropped against its time, a train went back to its time on the
* next render and took every untimed stop behind the drop with it, and the next drag
* stored that order (#2631). So it lands as close to the drop as its time allows and
* nothing else moves. A row without a time, or one the list draws where it was
* dropped, is returned as it came.
*/
export function seatTimedDrop(
order: readonly MergedItem[],
index: number,
dayId: number,
getDisplayTime: (r: MergedItem['data'], dayId: number) => string | null = getDisplayTimeForDay
): MergedItem[] {
const moved = order[index]
if (!moved || moved.type === 'place') return [...order]
const minutes = moved.type === 'note'
? parseTimeToMinutes(moved.data?.time)
: parseTimeToMinutes(getDisplayTime(moved.data, dayId))
if (minutes === null) return [...order]
const rest = order.filter((_, i) => i !== index)
const drawnAsIs = (at: number): MergedItem[] | null => {
const candidate = [...rest.slice(0, at), moved, ...rest.slice(at)]
const drawn = applyChronoOrder(candidate, dayId, getDisplayTime)
return drawn.every((it, i) => it === candidate[i]) ? candidate : null
}
for (let dist = 0; dist <= rest.length; dist++) {
for (const at of dist === 0 ? [index] : [index - dist, index + dist]) {
if (at < 0 || at > rest.length) continue
const seated = drawnAsIs(at)
if (seated) return seated
}
}
return [...order]
}
/** A row of the day list as the seat of a ride reads it; a note is none (see rideSeatKey). */
function seatItemsOf(row: MergedItem): SeatItem[] {
if (row.type === 'place') {
const place = row.data?.place
const located = Number.isFinite(place?.lat) && Number.isFinite(place?.lng)
return [{
key: row.sortKey,
minutes: parseTimeToMinutes(place?.place_time),
point: located ? { lat: place.lat, lng: place.lng } : null,
}]
}
if (row.type === 'transport') {
return [{ key: row.sortKey, minutes: parseTimeToMinutes(row.data?.reservation_time), point: null }]
}
return []
}
/**
* Where a booking without a saved slot goes among the day's rows by where it goes: the
* key of the row it goes behind, -Infinity to open the day, or null to leave it to the
* clock. Only a ride that leaves and lands on this day, with both terminals located,
* has a say (`sameDayRide`); the rule itself is `rideSeatAfter` in @trek/shared, which
* the road trip seats its terminals with, so the list and the drive keep one order.
*
* A place counts with its time and its coordinates, another booking with its time, a
* note not at all: the clock rule this refines never read a note's time either.
*/
export function rideSeatKey(r: CarrierBooking, rows: readonly MergedItem[]): number | null {
const ride = sameDayRide(r)
return ride ? rideSeatAfter(rows.flatMap(seatItemsOf), ride) : null
}
/** A booked stay, as far as the road trip times a stop by it. */
interface StayStart {
place_id?: number | null
start_day_id?: number | null
check_in?: string | null
}
/**
* The stay a stop at `placeId` begins on day `dayId`, the one whose check-in the road trip
* times that stop by. Only the day the stay starts: a check-out says when the room has to
* be handed back, not when the drive sets off.
*/
export function stayStartingOn<T extends StayStart>(stays: readonly T[], placeId: number | null | undefined, dayId: number): T | undefined {
return stays.find(stay => stay.place_id === placeId && stay.start_day_id === dayId)
}
/** A stored row of a day, as far as `storedRideSlot` reads it. */
interface StoredStop {
order_index: number
place_id?: number | null
accommodation_id?: number | null
place?: { lat?: number | null; lng?: number | null; place_time?: string | null } | null
}
/** Where the row stands, or null for a row without coordinates, which is no stop of the drive. */
function pointOf(row: StoredStop): { lat: number; lng: number } | null {
const lat = row.place?.lat
const lng = row.place?.lng
return typeof lat === 'number' && typeof lng === 'number' && Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null
}
/**
* The order indexes of the day's rows the road trip reads as a night, timed by the check-in
* of the stay that begins there (`stayStartingOn`): one per hotel, picked the way
* `oneNightPerStay` picks it over the rows the drive stops at (#2636). Another visit of
* the same hotel that day is a stop like any other.
*/
export function nightRowsOf(day: readonly StoredStop[], stays: readonly StayStart[], dayId: number): Set<number> {
const rows = day.filter(row => pointOf(row) !== null).sort((a, b) => a.order_index - b.order_index)
const nights = nightVisits(rows.map(row => ({
placeId: row.place_id ?? 0,
night: row.place_id != null && stayStartingOn(stays, row.place_id, dayId) !== undefined,
bookedNightId: row.accommodation_id ?? null,
})))
return new Set(rows.filter((_, i) => nights.has(i)).map(row => row.order_index))
}
/**
* The slot to store for a ride that lands on the day it left (`sameDayRide`) and has none
* yet: worked out over the day's rows the way the road trip seats its terminals among
* them (`seatCarrierStops`), so the slot the list is drawn by and the drive agree. Null for
* any other booking, which keeps the clock's slot.
*
* Read over every stored row the drive stops at, the ones the list hides included: the
* hotel a booking put on the day, a petrol station kept out of the list. The slot is a
* number among their order indexes all the same, and worked out over the rows the list
* shows it could land behind a hidden stop on the far shore, which the drive then went to
* overland before the crossing (#2461). Each row counts with its start, else the check-in
* of the stay that begins there that day on the row that is its night (`nightRowsOf`), and
* with its coordinates; a row without them is no stop of the drive. Where the rule keeps
* the clock's seat, the clock is read over the same rows: behind the last one timed at or
* before the departure, else at the end of the day.
*/
export function storedRideSlot(
r: CarrierBooking,
day: readonly StoredStop[],
stays: readonly StayStart[],
dayId: number,
): number | null {
const ride = sameDayRide(r)
if (!ride || !day.length) return null
const nights = nightRowsOf(day, stays, dayId)
const items: SeatItem[] = day.flatMap(row => {
const point = pointOf(row)
if (!point) return []
const start = row.place?.place_time ?? (nights.has(row.order_index) ? stayStartingOn(stays, row.place_id, dayId)?.check_in : null)
return [{ key: row.order_index, minutes: parseTimeToMinutes(start), point }]
})
const keys = day.map(row => row.order_index)
const seat = rideSeatAfter(items, ride)
if (seat === -Infinity) return Math.min(...keys) - 0.5
if (seat !== null) return seat + 0.5
const departs = ride.minutes ?? 0
const before = items.filter(item => item.minutes !== null && item.minutes <= departs).map(item => item.key)
return (before.length ? Math.max(...before) : Math.max(...keys)) + 0.5
}
/** Merge places, notes, and transports into a single ordered day timeline. */
export function getMergedItems(opts: {
dayAssignments: any[]
dayNotes: any[]
dayTransports: any[]
dayId: number
getDisplayTime?: (r: any, dayId: number) => string | null
}): MergedItem[] {
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
const baseItems: MergedItem[] = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
const timedTransports = transport.map(r => ({
type: 'transport' as const,
data: r,
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes)
if (timedTransports.length === 0) return applyChronoOrder(baseItems, dayId, getDisplayTime)
if (baseItems.length === 0) {
return applyChronoOrder(timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data })), dayId, getDisplayTime)
}
// Insert transports among base items based on per-day position or time
const result = [...baseItems]
for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti]
const minutes = timed.minutes
// Per-day position takes precedence (set by user reorder), then the booking's
// own slot. The map's waypoint builder has always read day_plan_position and
// this never did, so a booking carrying only that value sat between the places
// on the map and at the bottom of the day everywhere else (#2071).
const perDayPos = timed.data.day_positions?.[dayId]
?? timed.data.day_positions?.[String(dayId)]
?? timed.data.day_plan_position
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue
}
// Time-based fallback: insert after the last item whose time <= this transport's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
const pm = parseTimeToMinutes(item.data?.place?.place_time)
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
} else if (item.type === 'transport') {
const tm = parseTimeToMinutes(item.data?.reservation_time)
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
}
}
// A ride that lands today may sit elsewhere between the same clocks, where it adds
// the least road. Without that, a ferry between two untimed stops closed the day and
// the drive went to the far shore overland before the crossing (#2461).
const seat = rideSeatKey(timed.data, result)
if (seat !== null) insertAfterKey = seat
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
const firstKey = result.length > 0 ? Math.min(...result.map(i => i.sortKey)) : 0
const sortKey = seat === -Infinity
? firstKey - 0.5 - ti * 0.01
: insertAfterKey === -Infinity
? lastKey + 0.5 + ti * 0.01
: insertAfterKey + 0.01 + ti * 0.001
result.push({ type: timed.type, sortKey, data: timed.data })
}
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
}
/** A stored day row, as far as `timedSlot` reads it. */
interface StoredVisit {
order_index: number
accommodation_id?: number | null
place?: { place_time?: string | null } | null
}
/**
* Where a stop joining a day is stored when it carries a start of its own, as an index
* into the day's stored rows sorted by order_index. Null when it has none: that stop
* goes where it was dropped, as it always has.
*
* The list draws a day by time (`applyChronoOrder`) and the road trip drives it in the
* order it is stored. Put at the end of the day, or wherever it was dropped, a stop with
* a start sat in one place on the list and in another on the road, and behind a later
* start the road trip reached it late. So it is stored right behind the row it will be
* drawn after. Among stops without a time it keeps the spot it was dropped on; where the
* drop and the start disagree, the start wins.
*
* Read over every stored row, the ones the list hides included, because those are what
* the index counts. A booked night is timed by its check-in, the way the server's time
* sort reads it, so a stop pinned before the check-in lands ahead of the hotel and one
* pinned after it behind. A night without one counts as a stop without a time, so a
* night that leads its day keeps leading it.
*/
export function timedSlot(
day: readonly StoredVisit[],
nights: readonly { id: number; check_in?: string | null }[],
start: string | null | undefined,
dropAt?: number | null,
): number | null {
const minutes = parseTimeToMinutes(start)
if (minutes === null) return null
const stored = [...day].sort((a, b) => a.order_index - b.order_index)
const at = dropAt == null ? stored.length : Math.min(Math.max(dropAt, 0), stored.length)
const joining: StoredVisit = { order_index: -1 }
const startOf = (row: StoredVisit): number | null => {
if (row === joining) return minutes
const checkIn = row.accommodation_id == null ? null : nights.find(n => n.id === row.accommodation_id)?.check_in
return parseTimeToMinutes(row.place?.place_time ?? checkIn)
}
const drawn = chronoOrder([...stored.slice(0, at), joining, ...stored.slice(at)], startOf)
const before = drawn[drawn.indexOf(joining) - 1]
return before ? stored.indexOf(before) + 1 : 0
}