Files
TREK/client/src/utils/dayMerge.test.ts
T
Maurice 91630dd811 fix(planner): let a timed booking stay where it is dropped when only other bookings stand against its time
A booking dropped against another booking's time went back to the nearest
slot its own time allowed. With a 06:30 flight listed below the day's
untimed stops, every slot among them was against the flight, so a 13:00
transit dropped between two stops went back behind the flight on every
drop, and the flight's legs cannot be dragged out of the way.

The dropped row now stays where it was dropped and the other timed
bookings it puts out of order move next to it, the earlier ones ahead of
it and the later ones behind. Timed stops and notes never move: against
one of those the row is still seated by its time as before (#2631). The
phone's move applies the same rule before it refuses a move.

Closes #2983
2026-11-05 12:15:53 +01:00

796 lines
41 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, yieldTimedBookings, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
import { planMergedOrder, withTransportPositions } from './mergedOrder'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
expect(parseTimeToMinutes('09:30')).toBe(570)
})
it('parses ISO datetime string', () => {
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
})
it('returns null for null/empty', () => {
expect(parseTimeToMinutes(null)).toBeNull()
expect(parseTimeToMinutes(undefined)).toBeNull()
})
})
describe('getSpanPhase', () => {
it('returns single when start === end', () => {
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
})
it('returns start for the departure day', () => {
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
})
it('returns end for the arrival day', () => {
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
})
it('returns middle for days in between', () => {
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
})
})
describe('hidesOnMiddleDay', () => {
it('keeps a one-day parking on its only day', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 1 }, 1)).toBe(false)
})
it('keeps a two-day parking on both days (no day in between exists)', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 2 }
expect(hidesOnMiddleDay(parking, 1)).toBe(false)
expect(hidesOnMiddleDay(parking, 2)).toBe(false)
})
it('hides a three-day parking only on the day in between', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 3 }
expect(hidesOnMiddleDay(parking, 1)).toBe(false)
expect(hidesOnMiddleDay(parking, 2)).toBe(true)
expect(hidesOnMiddleDay(parking, 3)).toBe(false)
})
it('hides every day in between of a longer parking span (#1937)', () => {
const parking = { type: 'parking', day_id: 1, end_day_id: 5 }
expect([1, 2, 3, 4, 5].map(d => hidesOnMiddleDay(parking, d)))
.toEqual([false, true, true, true, false])
})
it('leaves a car rental visible, since its middle days move to the day header', () => {
expect(hidesOnMiddleDay({ type: 'car', day_id: 1, end_day_id: 3 }, 2)).toBe(false)
})
it('leaves every other booking type alone', () => {
for (const type of ['train', 'cruise', 'event', 'hotel', 'other']) {
expect(hidesOnMiddleDay({ type, day_id: 1, end_day_id: 3 }, 2)).toBe(false)
}
})
it('keeps a parking whose end day is not part of the trip', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 999 }, 1)).toBe(false)
})
it('keeps an unscheduled parking', () => {
expect(hidesOnMiddleDay({ type: 'parking', day_id: null, end_day_id: null }, 2)).toBe(false)
})
})
describe('getTransportRouteEndpoints', () => {
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
it('routes to the pickup only on the start day of a multi-day rental', () => {
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
})
it('routes from the drop-off only on the end day', () => {
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
})
it('adds no waypoints on the days in between (regression for #1210)', () => {
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
})
it('uses both endpoints for a single-day transport', () => {
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
from: { lat: 48.1, lng: 11.5 },
to: { lat: 52.5, lng: 13.4 },
})
})
it('returns nulls when the endpoints carry no coordinates', () => {
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
})
})
describe('getDisplayTimeForDay', () => {
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
it('returns reservation_time on start day', () => {
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
})
it('returns reservation_end_time on end day', () => {
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
})
it('returns null for middle day', () => {
expect(getDisplayTimeForDay(r, 2)).toBeNull()
})
})
describe('getTransportForDay', () => {
const days = [
{ id: 1, day_number: 1 },
{ id: 2, day_number: 2 },
{ id: 3, day_number: 3 },
]
it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes tour booking on the correct day', () => {
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes restaurant, event, and other bookings by day_id', () => {
const reservations = [
{ id: 30, type: 'restaurant', day_id: 2 },
{ id: 31, type: 'event', day_id: 2 },
{ id: 32, type: 'other', day_id: 2 },
]
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
})
it('includes multi-day transport on all spanned days', () => {
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
})
it('excludes transport linked to an assignment on that day', () => {
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
})
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
const reservations = [{
id: 40, type: 'train', day_id: 1, end_day_id: 2,
metadata: JSON.stringify({
train_number: 'ICE 100',
legs: [
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
],
}),
}]
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(day1).toHaveLength(1)
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
expect(day2).toHaveLength(1)
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
})
it('leaves a single-leg train untouched (no __leg)', () => {
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(rows).toHaveLength(1)
expect(rows[0].__leg).toBeUndefined()
})
})
describe('a booking pinned to a stop on another day (#2604)', () => {
const days = [
{ id: 1, day_number: 1 },
{ id: 2, day_number: 2 },
{ id: 3, day_number: 3 },
{ id: 4, day_number: 4 },
]
const order = (id: number) => days.find(d => d.id === id)?.day_number ?? null
const stopDays = stopDayIds({ '1': [{ id: 41 }], '3': [{ id: 43 }, { id: 44 }], '4': undefined })
it('maps every stop to its day', () => {
expect([...stopDays.entries()]).toEqual([[41, 1], [43, 3], [44, 3]])
})
it("a stop outside the booking's own days wins, a stop on one of them or a gone stop does not", () => {
expect(pinnedAway({ assignment_id: 43, day_id: 1 }, stopDays, order)).toBe(true)
expect(pinnedAway({ assignment_id: 41, day_id: 1 }, stopDays, order)).toBe(false)
// A rental picked up at a stop still runs across the rest of its days.
expect(pinnedAway({ assignment_id: 43, day_id: 2, end_day_id: 4 }, stopDays, order)).toBe(false)
expect(pinnedAway({ assignment_id: 99, day_id: 1 }, stopDays, order)).toBe(false)
expect(pinnedAway({ assignment_id: null, day_id: 1 }, stopDays, order)).toBe(false)
expect(pinnedAway({ assignment_id: 43, day_id: null }, stopDays, order)).toBe(false)
expect(pinnedAway({ assignment_id: 43, day_id: 1 }, undefined, order)).toBe(false)
})
it("the timeline of the booking's own day leaves it to the stop, so it is listed once", () => {
const dinner = { id: 10, type: 'restaurant', day_id: 1, assignment_id: 43 }
const reservations = [dinner]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [41], days, stopDays })).toHaveLength(0)
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [43, 44], days, stopDays })).toHaveLength(0)
expect(getAssignmentReservations(reservations, 43)).toEqual([dinner])
// Without the stops of the trip the old rule stands.
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [41], days })).toHaveLength(1)
})
it('a multi-day booking pinned to a stop on one of its days keeps its other days', () => {
const rental = { id: 11, type: 'event', day_id: 2, end_day_id: 4, assignment_id: 43 }
expect(getTransportForDay({ reservations: [rental], dayId: 2, dayAssignmentIds: [], days, stopDays })).toHaveLength(1)
expect(getTransportForDay({ reservations: [rental], dayId: 3, dayAssignmentIds: [43, 44], days, stopDays })).toHaveLength(0)
})
})
describe('getPlaceBookings (#2363)', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: null, reservation_time: '2025-06-01T15:00:00', accommodation_place_id: 9 },
{ id: 3, assignment_id: 42, reservation_time: '2025-06-01T09:00:00', accommodation_place_id: 9 },
{ id: 4, assignment_id: null, reservation_time: null, accommodation_place_id: 8 },
]
it('adds the stay booked for a hotel after the bookings pinned to the stop, each once', () => {
expect(getPlaceBookings(reservations, 42, 9).map(r => r.id)).toEqual([3, 1, 2])
})
it('finds the stay even when no stop is open, as from the pill in the day head', () => {
expect(getPlaceBookings(reservations, null, 9).map(r => r.id)).toEqual([2, 3])
})
it('without a place it is the pinned list alone', () => {
expect(getPlaceBookings(reservations, 42, null).map(r => r.id)).toEqual([3, 1])
expect(getPlaceBookings(reservations, null, 7)).toEqual([])
})
})
describe('getAssignmentReservations', () => {
it('returns every booking pinned to the assignment, not just the first (#2201)', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
{ id: 3, assignment_id: 7, reservation_time: '2025-06-01T08:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([2, 1])
})
it('puts untimed bookings last and breaks ties on the id', () => {
const reservations = [
{ id: 5, assignment_id: 42, reservation_time: null },
{ id: 4, assignment_id: 42, reservation_time: null },
{ id: 6, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([6, 4, 5])
})
it('returns nothing without an assignment', () => {
const reservations = [{ id: 1, assignment_id: 42, reservation_time: null }]
expect(getAssignmentReservations(reservations, null)).toEqual([])
expect(getAssignmentReservations(reservations, undefined)).toEqual([])
})
it('leaves the caller array untouched', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
getAssignmentReservations(reservations, 42)
expect(reservations.map(r => r.id)).toEqual([1, 2])
})
})
describe('getMergedItems', () => {
it('merges places and notes sorted by sortKey', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: null } },
{ id: 2, order_index: 2, place: { place_time: null } },
]
const dayNotes = [{ id: 10, sort_order: 1 }]
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
expect(result[0].data.id).toBe(1)
expect(result[1].data.id).toBe(10)
expect(result[2].data.id).toBe(2)
})
it('inserts transport by time when no per-day position is set', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
]
const dayTransports = [
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
]
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
const types = result.map(i => i.type)
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
expect(types).toEqual(['place', 'transport', 'place'])
})
it('orders a timed transport chronologically regardless of a stale per-day position', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
]
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
// timed items are arranged chronologically even if an old manual position exists.
const dayTransports = [
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
]
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
const types = result.map(i => i.type)
expect(types).toEqual(['place', 'transport', 'place'])
})
// The same rule the server stores when a start time is saved, so on a day of places
// alone the day it sends back after the save and the day drawn before it agree.
it('keeps untimed places where they were put and sorts only the timed ones', () => {
const place = (id: number, order_index: number, place_time: string | null) => ({ id, order_index, place: { place_time } })
const ids = (dayAssignments: ReturnType<typeof place>[]) =>
getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [], dayId: 5 }).map(i => i.data.id)
expect(ids([place(1, 0, null), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3])
expect(ids([place(1, 0, '09:00'), place(2, 1, null), place(3, 2, '14:00')])).toEqual([1, 2, 3])
expect(ids([place(1, 0, null), place(2, 1, '15:00'), place(3, 2, '10:00')])).toEqual([1, 3, 2])
})
// Where the server's sort and this one part: the server sorts the stops alone, so
// for it place 2 follows the 09:00 place and stays in front of the 10:00 one. Here it
// follows the 12:00 note and is drawn behind both.
it('lets an untimed place take the time of a timed note in front of it', () => {
const dayAssignments = [
{ id: 1, order_index: 0, place: { place_time: '09:00' } },
{ id: 2, order_index: 1, place: { place_time: null } },
{ id: 3, order_index: 2, place: { place_time: '10:00' } },
]
const dayNotes = [{ id: 10, sort_order: 0.5, time: '12:00' }]
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
expect(result.map(i => i.data.id)).toEqual([1, 3, 10, 2])
})
})
// The crossing from the report behind #2461: Amsterdam and Newcastle on the day, the
// ferry from IJmuiden to the Port of Tyne in the evening. The road trip seats its
// terminals by the same rule (`rideSeatAfter` in @trek/shared), so list and drive agree.
describe('getMergedItems: a ride within one day, seated by where it goes (#2461)', () => {
const place = (id: number, order_index: number, lat: number, lng: number, place_time: string | null = null) =>
({ id, order_index, place: { lat, lng, place_time } })
const amsterdam = (at: string | null = null) => place(1, 0, 52.3731, 4.8926, at)
const newcastle = (at: string | null = null) => place(2, 1, 54.9783, -1.6178, at)
const ferry = (over: Record<string, unknown> = {}) => ({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 5, end_day_id: 5,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', lat: 52.4581, lng: 4.5879 },
{ role: 'to', sequence: 1, name: 'Port of Tyne', lat: 54.9925, lng: -1.4522 },
],
...over,
})
const read = (dayAssignments: unknown[], dayTransports: unknown[], dayNotes: unknown[] = []) =>
getMergedItems({ dayAssignments, dayNotes, dayTransports, dayId: 5 }).map(i => `${i.type}:${i.data.id}`)
it('puts a ferry between the stops on its two shores rather than at the end of the day', () => {
expect(read([amsterdam(), newcastle()], [ferry()])).toEqual(['place:1', 'transport:69', 'place:2'])
})
it('keeps it behind a stop timed before its departure, and leaves a ferry without terminals to the clock', () => {
expect(read([amsterdam(), newcastle('10:00')], [ferry()])).toEqual(['place:1', 'place:2', 'transport:69'])
expect(read([amsterdam(), newcastle()], [ferry({ endpoints: [] })])).toEqual(['place:1', 'place:2', 'transport:69'])
// One that lands tomorrow has no seat of its own on this day either.
expect(read([amsterdam(), newcastle()], [ferry({ end_day_id: 6 })])).toEqual(['place:1', 'place:2', 'transport:69'])
})
it('opens the day with a ride that lands next to its first stop, ahead of a note too', () => {
// The note keeps its place ahead of the stop; the ride goes ahead of both.
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 0 }])).toEqual(['transport:69', 'note:10', 'place:2'])
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 2 }])).toEqual(['transport:69', 'place:2', 'note:10'])
})
it('leaves a slot somebody stored where it is', () => {
expect(read([amsterdam(), newcastle()], [ferry({ day_positions: { 5: 1.5 } })])).toEqual(['place:1', 'place:2', 'transport:69'])
})
it('reads a note as nothing and another booking as its clock only', () => {
const rows = [
{ type: 'place' as const, sortKey: 0, data: amsterdam() },
{ type: 'note' as const, sortKey: 0.5, data: { id: 10, time: '20:00' } },
{ type: 'place' as const, sortKey: 1, data: newcastle() },
]
expect(rideSeatKey(ferry(), rows)).toBe(0)
// A bus at 18:00 between the two is a clock after the departure: the ferry stays ahead of it.
const bus = { type: 'transport' as const, sortKey: 0.5, data: { id: 11, type: 'bus', reservation_time: '18:00' } }
expect(rideSeatKey(ferry(), [rows[0], bus, rows[2]])).toBeNull()
expect(rideSeatKey({ ...ferry(), type: 'taxi' }, rows)).toBeNull()
})
})
// The slot the desktop stores for a ride nobody placed yet, worked out over the rows the
// road trip drives rather than the ones the list shows (#2461).
describe('storedRideSlot', () => {
const row = (id: number, order_index: number, lat: number | null, lng: number | null, place_time: string | null = null) =>
({ id, order_index, place_id: id * 10, place: { lat, lng, place_time } })
const amsterdam = row(1, 0, 52.3731, 4.8926)
// The hotel a booking put on the day: hidden in the list, a stop of the drive.
const hotel = row(3, 1, 54.975, -1.61)
const ferry = (over: Record<string, unknown> = {}) => ({
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 5, end_day_id: 5,
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'IJmuiden', lat: 52.4581, lng: 4.5879 },
{ role: 'to', sequence: 1, name: 'Port of Tyne', lat: 54.9925, lng: -1.4522 },
],
...over,
})
it('puts a ferry ahead of the hotel across the water that the list does not show', () => {
// Over the visible Amsterdam alone the clock closed the day at 1.5, behind the hotel,
// and the drive went there overland before the crossing.
expect(storedRideSlot(ferry(), [amsterdam, hotel], [{ place_id: 30, start_day_id: 5, check_in: null }], 5)).toBe(0.5)
})
it('times a night by the check-in of the stay that starts there, and the clock still binds', () => {
// Checked in at three, the hotel is behind the drive by the 17:30 sailing already.
const checkIn = [{ place_id: 30, start_day_id: 5, check_in: '15:00' }]
expect(storedRideSlot(ferry(), [amsterdam, hotel], checkIn, 5)).toBe(1.5)
// A stay that starts on another day says nothing about this one.
expect(storedRideSlot(ferry(), [amsterdam, hotel], [{ ...checkIn[0], start_day_id: 4 }], 5)).toBe(0.5)
})
it('times only the last visit of the hotel by its check-in, an earlier one is a stop (#2636)', () => {
// Checked in at six, after the 17:30 sailing: the night is behind the crossing, and so
// is the rest at the same hotel before it. Read as a second check-in it timed the rest
// at six too, and the slot went behind both, so the drive reached them overland.
const checkIn = [{ place_id: 30, start_day_id: 5, check_in: '18:00' }]
const night = { ...hotel, id: 4, order_index: 2 }
expect(storedRideSlot(ferry(), [amsterdam, hotel, night], checkIn, 5)).toBe(0.5)
})
it("reads the booking's stop as the night over a rest after it, and a visit that ends the day over both (#2636)", () => {
const stays = [{ place_id: 30, start_day_id: 5, check_in: '18:00' }]
// The booking seats its stop first on a day without hours, and Days does not show it.
const booked = { ...hotel, order_index: 0, accommodation_id: 7 }
const rest = { ...hotel, id: 4, order_index: 2 }
const after = row(5, 3, 54.9783, -1.6178)
expect([...nightRowsOf([booked, { ...amsterdam, order_index: 1 }, rest, after], stays, 5)]).toEqual([0])
expect([...nightRowsOf([booked, { ...amsterdam, order_index: 1 }, rest], stays, 5)]).toEqual([2])
// Without a stay starting there that day nothing is a night.
expect([...nightRowsOf([booked, rest], stays, 6)]).toEqual([])
})
it('opens the day, closes it, or sits behind a timed row, measured on order indexes', () => {
expect(storedRideSlot(ferry(), [row(2, 3, 54.9783, -1.6178)], [], 5)).toBe(2.5)
expect(storedRideSlot(ferry(), [amsterdam], [], 5)).toBe(0.5)
expect(storedRideSlot(ferry(), [amsterdam, row(2, 1, 54.9783, -1.6178, '10:00')], [], 5)).toBe(1.5)
// A row without coordinates is no stop of the drive, but the day still closes behind it.
expect(storedRideSlot(ferry(), [amsterdam, row(4, 1, null, null)], [], 5)).toBe(1.5)
})
it('leaves anything but a ride within one day to the clock', () => {
expect(storedRideSlot(ferry({ type: 'taxi' }), [amsterdam, hotel], [], 5)).toBeNull()
expect(storedRideSlot(ferry({ end_day_id: 6 }), [amsterdam, hotel], [], 5)).toBeNull()
expect(storedRideSlot(ferry(), [], [], 5)).toBeNull()
})
it('finds the stay by its place and the day it starts', () => {
const stays = [{ id: 1, place_id: 30, start_day_id: 4 }, { id: 2, place_id: 30, start_day_id: 5 }]
expect(stayStartingOn(stays, 30, 5)?.id).toBe(2)
expect(stayStartingOn(stays, 31, 5)).toBeUndefined()
})
})
// A stop with a start is drawn by it, and the road trip drives the stored order, so a
// stop joining a day from elsewhere is stored where the list will draw it.
describe('timedSlot', () => {
const visit = (order_index: number, place_time: string | null, accommodation_id: number | null = null) =>
({ order_index, accommodation_id, place: { place_time } })
const day = [visit(0, '09:00'), visit(1, '11:00'), visit(2, '14:00')]
it('leaves a stop without a start to the drop', () => {
expect(timedSlot(day, [], null, 1)).toBeNull()
expect(timedSlot(day, [], undefined)).toBeNull()
expect(timedSlot(day, [], 'soon')).toBeNull()
})
it('puts a stop between the stored stops its start falls between, wherever it was dropped', () => {
expect(timedSlot(day, [], '10:00')).toBe(1)
expect(timedSlot(day, [], '10:00', 2)).toBe(1)
expect(timedSlot(day, [], '10:00', 0)).toBe(1)
expect(timedSlot(day, [], '08:00')).toBe(0)
expect(timedSlot(day, [], '15:00', 0)).toBe(3)
})
it('keeps the drop among the stops without a time behind the same start', () => {
const loose = [visit(0, '09:00'), visit(1, null), visit(2, '11:00')]
expect(timedSlot(loose, [], '10:00')).toBe(2)
expect(timedSlot(loose, [], '10:00', 1)).toBe(1)
})
it('reads the day by order_index, not by the order the rows come in', () => {
const shuffled = [visit(2, '14:00'), visit(0, '09:00'), visit(1, '11:00')]
expect(timedSlot(shuffled, [], '12:00')).toBe(2)
expect(timedSlot([], [], '12:00')).toBe(0)
})
it('stores it right behind the stop the list draws it after on a day stored against its hours', () => {
// Stored 11:00 then 09:00: the list draws 09:00, 10:00, 11:00, so 10:00 goes behind 09:00.
expect(timedSlot([visit(0, '11:00'), visit(1, '09:00')], [], '10:00')).toBe(2)
})
it('counts the booked night the list hides, timed by its check-in', () => {
const nights = [{ id: 7, check_in: '15:00' }, { id: 8, check_in: null }]
const withCheckIn = [visit(0, '09:00'), visit(1, null, 7), visit(2, '16:00')]
expect(timedSlot(withCheckIn, nights, '12:00')).toBe(1)
expect(timedSlot(withCheckIn, nights, '15:30')).toBe(2)
// Without a check-in the night leads the day and stays in front.
const leading = [visit(0, null, 8), visit(1, '09:00')]
expect(timedSlot(leading, nights, '08:00')).toBe(1)
})
})
// #2631: a day with timed and untimed stops between timed transit rides. Dropping a
// ride against its time used to draw it back at its time with every untimed stop
// behind the drop, and the next drag stored that shuffle.
describe('seatTimedDrop', () => {
const DAY = 1
const stop = (id: number, name: string, time: string | null = null) =>
({ id, order_index: 0, place: { name, place_time: time } })
const ride = (id: number, time: string, pos: number) =>
({ id, type: 'transit', title: `ride ${time}`, day_id: DAY, reservation_time: `2026-11-20T${time}`, day_positions: { [DAY]: pos } })
const note = (id: number, time: string | null, sort_order: number) => ({ id, text: `note ${id}`, time, sort_order })
const setup = () => {
const stops = [
stop(1, 'Palacio', '07:30'), stop(2, 'Jardin', '10:30'), stop(3, 'Asamkirche', '14:00'), stop(4, 'StPeter', '14:30'),
stop(5, 'Sendlinger'), stop(6, 'Ayuntamiento'), stop(7, 'Isartor'), stop(8, 'Juliet'),
].map((a, i) => ({ ...a, order_index: i }))
return { stops, rides: [ride(101, '07:01', -0.5), ride(102, '12:01', 1.5), ride(103, '16:55', 3.5)], notes: [] as ReturnType<typeof note>[] }
}
type Day = ReturnType<typeof setup>
const draw = (day: Day) => getMergedItems({ dayAssignments: day.stops, dayNotes: day.notes, dayTransports: day.rides, dayId: DAY })
const label = (it: MergedItem) => it.type === 'place' ? it.data.place.name : it.type === 'note' ? it.data.text : it.data.title
/** What the day list does with a drop: insert, seat, store, and draw again. */
const drop = (day: Day, from: (it: MergedItem) => boolean, to: (it: MergedItem) => boolean, after = true) => {
const m = draw(day)
const order = [...m]
const [moved] = order.splice(order.findIndex(from), 1)
const at = order.findIndex(to) + (after ? 1 : 0)
order.splice(at, 0, moved)
const seated = seatTimedDrop(order, at, DAY)
const plan = planMergedOrder(seated)
const stored: Day = {
stops: day.stops.map(a => ({ ...a, order_index: plan.assignmentIds.indexOf(a.id) })),
rides: withTransportPositions(day.rides, plan.transportUpdates, DAY),
notes: day.notes.map(n => ({ ...n, sort_order: plan.noteUpdates.find(u => u.id === n.id)?.sort_order ?? n.sort_order })),
}
return { seated: seated.map(label), drawn: draw(stored).map(label), stored }
}
const isRide = (id: number) => (it: MergedItem) => it.type === 'transport' && it.data.id === id
const isStop = (name: string) => (it: MergedItem) => it.type === 'place' && it.data.place.name === name
const stopsOf = (labels: string[]) => labels.filter(l => !l.startsWith('ride') && !l.startsWith('note'))
it('draws the day as the setup expects', () => {
expect(draw(setup()).map(label)).toEqual([
'ride 07:01', 'Palacio', 'Jardin', 'ride 12:01', 'Asamkirche', 'StPeter', 'ride 16:55', 'Sendlinger', 'Ayuntamiento', 'Isartor', 'Juliet',
])
})
it('a ride dropped against its time goes back alone and the stops keep their order', () => {
const day = setup()
const before = stopsOf(draw(day).map(label))
const { seated, drawn } = drop(day, isRide(102), isStop('Sendlinger'))
expect(drawn).toEqual(seated)
expect(stopsOf(drawn)).toEqual(before)
expect(drawn.indexOf('ride 12:01')).toBe(drawn.indexOf('Asamkirche') - 1)
})
it('a later drag of another ride does not reshuffle the stops either', () => {
const first = drop(setup(), isRide(102), isStop('Sendlinger'))
const { seated, drawn } = drop(first.stored, isRide(101), isStop('Ayuntamiento'))
expect(drawn).toEqual(seated)
expect(stopsOf(drawn)).toEqual(['Palacio', 'Jardin', 'Asamkirche', 'StPeter', 'Sendlinger', 'Ayuntamiento', 'Isartor', 'Juliet'])
expect(drawn[0]).toBe('ride 07:01')
})
it('a ride dropped between untimed stops its time allows stays where it was dropped', () => {
const { seated, drawn } = drop(setup(), isRide(103), isStop('Isartor'))
expect(drawn).toEqual(seated)
expect(drawn.slice(-4)).toEqual(['Ayuntamiento', 'Isartor', 'ride 16:55', 'Juliet'])
})
it('leaves the untimed stops behind a ride where they are, as before', () => {
const day: Day = {
stops: [stop(1, 'Hotel'), stop(2, 'Museum'), stop(3, 'Dinner')].map((a, i) => ({ ...a, order_index: i })),
rides: [ride(101, '09:00', 0.5)],
notes: [],
}
expect(draw(day).map(label)).toEqual(['Hotel', 'ride 09:00', 'Museum', 'Dinner'])
const { seated, drawn } = drop(day, isRide(101), isStop('Museum'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Museum', 'ride 09:00', 'Dinner'])
})
it('seats a timed note the same way and leaves an untimed one where it was dropped', () => {
const day = setup()
day.notes = [note(201, '12:30', 1.6), note(202, null, 6.5)]
const isNote = (id: number) => (it: MergedItem) => it.type === 'note' && it.data.id === id
const timed = drop(day, isNote(201), isStop('Isartor'))
expect(timed.drawn).toEqual(timed.seated)
expect(timed.drawn.indexOf('note 201')).toBe(timed.drawn.indexOf('Asamkirche') - 1)
const untimed = drop(day, isNote(202), isStop('Palacio'))
expect(untimed.drawn).toEqual(untimed.seated)
expect(untimed.drawn.indexOf('note 202')).toBe(untimed.drawn.indexOf('Palacio') + 1)
})
it('returns a dropped stop as it came', () => {
const order = draw(setup())
expect(seatTimedDrop(order, 1, DAY)).toEqual(order)
})
// #2983: a flight listed below the day's untimed stops held every slot among them.
const untimedDay = (rides: ReturnType<typeof ride>[]): Day => ({
stops: [stop(1, 'Hotel'), stop(2, 'Shrine'), stop(3, 'Crossing'), stop(4, 'Tower')].map((a, i) => ({ ...a, order_index: i })),
rides,
notes: [],
})
it('a ride dropped among untimed stops stays there, and an earlier booking below them makes room ahead of it', () => {
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 06:30', 'ride 13:00'])
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'ride 13:00', 'Crossing', 'Tower'])
})
it('a later booking ahead of the drop moves behind it, and the stops keep their order', () => {
const day = untimedDay([ride(101, '09:00', -0.5), ride(102, '18:00', 1.5)])
expect(draw(day).map(label)).toEqual(['ride 09:00', 'Hotel', 'Shrine', 'ride 18:00', 'Crossing', 'Tower'])
const { seated, drawn } = drop(day, isRide(101), isStop('Tower'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'Crossing', 'Tower', 'ride 09:00', 'ride 18:00'])
})
it('a timed stop in the way still sends the ride back to its time, the bookings stay put', () => {
const day = untimedDay([ride(101, '06:30', 3.5), ride(102, '13:00', 3.6)])
day.stops[2] = { ...day.stops[2], place: { name: 'Crossing', place_time: '12:00' } }
expect(draw(day).map(label)).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'Tower', 'ride 13:00'])
const { seated, drawn } = drop(day, isRide(102), isStop('Shrine'))
expect(drawn).toEqual(seated)
expect(drawn).toEqual(['Hotel', 'Shrine', 'ride 06:30', 'Crossing', 'ride 13:00', 'Tower'])
})
})
describe('yieldTimedBookings', () => {
const DAY = 1
const row = (type: MergedItem['type'], data: Record<string, unknown>): MergedItem => ({ type, sortKey: 0, data })
const place = (id: number) => row('place', { id, place: { place_time: null } })
const booking = (id: number, time: string | null) => row('transport', { id, day_id: DAY, reservation_time: time })
it('leaves an untimed row, a stop and an order without a booking out of place to the caller', () => {
const order = [place(1), booking(2, null), booking(3, '2026-11-20T08:00')]
expect(yieldTimedBookings(order, 1, DAY)).toBeNull()
expect(yieldTimedBookings(order, 0, DAY)).toBeNull()
expect(yieldTimedBookings(order, 2, DAY)).toBeNull()
})
})
// The report behind #2929: Auckland to Los Angeles leaves at 20:20 and lands at 12:25 the
// same date, local time at each end; the flight on to Seattle leaves Los Angeles at 17:00.
// By the clock alone 17:00 comes first, so the second flight was drawn above the first.
describe('getMergedItems: bookings in different time zones (#2929)', () => {
const DAY = 5
const days = [{ id: DAY, day_number: 1, date: '2026-11-20' }]
const flight = (id: number, time: string, end: string, from: string | null, to: string | null) => ({
id, type: 'flight', day_id: DAY, end_day_id: DAY,
reservation_time: `2026-11-20T${time}`, reservation_end_time: `2026-11-20T${end}`,
metadata: JSON.stringify({ ...(from ? { departure_timezone: from } : {}), ...(to ? { arrival_timezone: to } : {}) }),
})
const toLA = flight(1, '20:20', '12:25', 'Pacific/Auckland', 'America/Los_Angeles')
const toSeattle = flight(2, '17:00', '19:45', 'America/Los_Angeles', 'America/Los_Angeles')
const ids = (items: MergedItem[]) => items.map(i => i.data.id)
it('puts the Auckland flight ahead of the Los Angeles one it connects to', () => {
for (const dayTransports of [[toLA, toSeattle], [toSeattle, toLA]]) {
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }))).toEqual([1, 2])
}
})
it('keeps the stops of the day where their clock puts them', () => {
const dayAssignments = [
{ id: 10, order_index: 0, place: { place_time: '09:00' } },
{ id: 11, order_index: 1, place: { place_time: '18:00' } },
]
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
// The flights swap the slots they hold between them (17:00 and 20:20); the stops stay put.
expect(result.map(i => `${i.type}:${i.data.id}`)).toEqual(['place:10', 'transport:1', 'place:11', 'transport:2'])
})
it('reads the zone from the endpoints when the metadata names none', () => {
const endpointsOf = (fromTz: string, toTz: string) => [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 0, lng: 0, timezone: fromTz, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 0, lng: 0, timezone: toTz, local_time: null, local_date: null },
]
const a = { ...flight(1, '20:20', '12:25', null, null), endpoints: endpointsOf('Pacific/Auckland', 'America/Los_Angeles') }
const b = { ...flight(2, '17:00', '19:45', null, null), endpoints: endpointsOf('America/Los_Angeles', 'America/Los_Angeles') }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [b, a], dayId: DAY }))).toEqual([1, 2])
})
it('orders the legs of one multi-leg booking by when they leave', () => {
const booking = {
id: 3, type: 'flight', day_id: DAY, end_day_id: DAY,
reservation_time: '2026-11-20T20:20', reservation_end_time: '2026-11-20T19:45',
endpoints: [
{ role: 'from', sequence: 0, name: 'Auckland', code: 'AKL', lat: -37.0, lng: 174.8, timezone: 'Pacific/Auckland', local_time: null, local_date: null },
{ role: 'stop', sequence: 1, name: 'Los Angeles', code: 'LAX', lat: 33.9, lng: -118.4, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
{ role: 'to', sequence: 2, name: 'Seattle', code: 'SEA', lat: 47.4, lng: -122.3, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
],
metadata: JSON.stringify({ legs: [
{ from: 'AKL', to: 'LAX', dep_day_id: DAY, dep_time: '20:20', arr_day_id: DAY, arr_time: '12:25' },
{ from: 'LAX', to: 'SEA', dep_day_id: DAY, dep_time: '17:00', arr_day_id: DAY, arr_time: '19:45' },
] }),
}
const dayTransports = getTransportForDay({ reservations: [booking], dayId: DAY, dayAssignmentIds: [], days })
const legs = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }).map(i => i.data.__leg.from)
expect(legs).toEqual(['AKL', 'LAX'])
})
it('orders by the clock when a booking names no zone', () => {
const noZone = flight(1, '20:20', '12:25', null, null)
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [noZone, toSeattle], dayId: DAY }))).toEqual([2, 1])
})
it('orders by the clock when the time carries no date', () => {
const a = { ...toLA, reservation_time: '20:20' }
const b = { ...toSeattle, reservation_time: '17:00' }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [a, b], dayId: DAY }))).toEqual([2, 1])
})
it('reads the arrival zone on the day an overnight flight lands', () => {
const NEXT = 6
// Leaves Auckland the evening before and lands in Los Angeles at 12:25 local (20:25 UTC).
// A flight out of New York at 14:00 local (19:00 UTC) left before it, though its clock
// reads later, and 12:25 read in Auckland's zone would put the landing a day early.
const overnight = {
id: 1, type: 'flight', day_id: DAY, end_day_id: NEXT,
reservation_time: '2026-11-19T20:20', reservation_end_time: '2026-11-21T12:25',
metadata: JSON.stringify({ departure_timezone: 'Pacific/Auckland', arrival_timezone: 'America/Los_Angeles' }),
}
const hop = { id: 2, type: 'flight', day_id: NEXT, end_day_id: NEXT, reservation_time: '2026-11-21T14:00',
metadata: JSON.stringify({ departure_timezone: 'America/New_York' }) }
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [overnight, hop], dayId: NEXT }))).toEqual([2, 1])
})
it('seats a dragged flight where its zone puts it', () => {
const order = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toLA, toSeattle], dayId: DAY })
expect(ids(seatTimedDrop([order[1], order[0]], 0, DAY))).toEqual([1, 2])
})
it('gives the zoned flights the key they are drawn by, so the drawn order reads as in order', () => {
const items = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
const keyOf = chronoKeyOf(items, DAY, getDisplayTimeForDay)
// Drawn Auckland first: it takes the 17:00 slot, the Seattle flight the 20:20 one.
expect(items.map(keyOf)).toEqual([17 * 60, 20 * 60 + 20])
})
})