mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): a booked ferry is driven to, and one without a date says so
A ferry between two stops without a clock was seated by the clock alone, which files a ride with nothing timed before it at the end of the day. The desktop day list stored that slot, and the road trip drove overland to the far shore and back to the pier before the crossing. A ride that leaves and lands on the same day, with both terminals located, now takes the seat between the clocks that adds the fewest straight-line kilometres. One rule in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists and the road trip in the browser and on the server. Slots already stored are left alone. A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the map still drew its arc. The rail and the phone tab now name such a booking with a way to open it, and calculate_roadtrip lists it under undatedRides.
This commit is contained in:
@@ -2522,6 +2522,55 @@ describe('DayPlanSidebar', () => {
|
||||
expect(stored!.day_plan_position).toBe(positions[1].day_plan_position)
|
||||
})
|
||||
|
||||
// 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 slot written here is the
|
||||
// one every other reader takes from then on, the road trip included.
|
||||
const crossing = (newcastleAt: string | null, withAmsterdam = true) => {
|
||||
const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' })
|
||||
const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 })
|
||||
const newcastle = buildPlace({ id: 2, name: 'Newcastle', lat: 54.9783, lng: -1.6178, place_time: newcastleAt })
|
||||
const stops = [
|
||||
buildAssignment({ id: 11, day_id: 10, order_index: 0, place: amsterdam }),
|
||||
buildAssignment({ id: 12, day_id: 10, order_index: 1, place: newcastle }),
|
||||
].filter(a => withAmsterdam || a.id !== 11)
|
||||
const ferry = buildReservation({
|
||||
id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10,
|
||||
reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
})
|
||||
seedStore(useTripStore, { reservations: [ferry] })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [amsterdam, newcastle], reservations: [ferry], assignments: { '10': stops },
|
||||
})} />)
|
||||
}
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-218: a ferry between two untimed stops on its two shores is given the slot between them (#2461)', async () => {
|
||||
const { reservationsApi } = await import('../../api/client')
|
||||
crossing(null)
|
||||
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
|
||||
// By the clock alone it closed the day at 2.5, and the drive went overland first.
|
||||
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
|
||||
expect(useTripStore.getState().reservations.find(r => r.id === 69)!.day_plan_position).toBe(0.5)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-219: a stop timed before the ferry keeps it behind that stop (#2461)', async () => {
|
||||
const { reservationsApi } = await import('../../api/client')
|
||||
crossing('10:00')
|
||||
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
|
||||
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 1.5 }])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-220: a ferry landing next to the only stop of the day opens it (#2461)', async () => {
|
||||
const { reservationsApi } = await import('../../api/client')
|
||||
crossing(null, false)
|
||||
await waitFor(() => expect(vi.mocked(reservationsApi.updatePositions)).toHaveBeenCalled())
|
||||
// Ahead of Newcastle, which is stored at order_index 1 here.
|
||||
expect(vi.mocked(reservationsApi.updatePositions).mock.calls[0][1]).toEqual([{ id: 69, day_plan_position: 0.5 }])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-204: a rejected slot write puts the bookings back where the server has them', async () => {
|
||||
const { reservationsApi } = await import('../../api/client')
|
||||
vi.mocked(reservationsApi.updatePositions).mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
@@ -35,7 +35,7 @@ import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
|
||||
getAssignmentReservations, timedSlot,
|
||||
getAssignmentReservations, timedSlot, rideSeatKey,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
@@ -439,6 +439,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
// A ride that lands today goes where it adds the least road between the same clocks,
|
||||
// by the rule the list and the road trip seat it with (`rideSeatKey`). This slot is
|
||||
// stored, so seated by the clock alone a ferry between two untimed stops stayed at
|
||||
// the end of the day, and the drive went overland before the crossing (#2461).
|
||||
const seat = rideSeatKey(r, da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })))
|
||||
if (seat === -Infinity) return Math.min(...da.map(a => a.order_index)) - 0.5
|
||||
if (seat !== null) return seat + 0.5
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
// Find the last place with time <= transport time
|
||||
let afterIdx = -1
|
||||
|
||||
@@ -1397,4 +1397,41 @@ describe('RoadtripSidebar with the drive in from the day before (#2461)', () =>
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [joined({ arrivingLine: undefined })] })} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('a ride on no day (#2461)', () => {
|
||||
const terminals = [
|
||||
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null },
|
||||
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null },
|
||||
]
|
||||
const ferry = (over: Partial<Reservation>): Reservation =>
|
||||
({ id: 70, trip_id: 1, type: 'ferry', title: 'IJmuiden to Newcastle', status: 'confirmed', day_id: null, endpoints: terminals, ...over }) as unknown as Reservation
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-062: is named under the totals with its booking a click away; one on a day or without terminals is not', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const reservations = [
|
||||
ferry({}),
|
||||
// On a day it is on the drive already; without terminals nothing says where it runs.
|
||||
ferry({ id: 71, title: 'Dated ferry', day_id: 1 }),
|
||||
ferry({ id: 72, title: 'Unlocated ferry', endpoints: [] } as Partial<Reservation>),
|
||||
ferry({ id: 73, title: 'Table', type: 'restaurant' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes()} reservations={reservations} onOpenBooking={onOpenBooking} />)
|
||||
|
||||
const notice = screen.getByRole('status')
|
||||
expect(within(notice).getByText('IJmuiden to Newcastle has no date, so the drive does not use it.')).toBeInTheDocument()
|
||||
expect(within(notice).getAllByRole('listitem')).toHaveLength(1)
|
||||
fireEvent.click(within(notice).getByRole('button', { name: 'Open booking' }))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-063: says so without a button where bookings cannot be opened, and says nothing when every ride has a day', () => {
|
||||
const reader = wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({})]} />)
|
||||
expect(screen.getByText('IJmuiden to Newcastle has no date, so the drive does not use it.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open booking' })).toBeNull()
|
||||
reader.unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes()} reservations={[ferry({ day_id: 1 })]} onOpenBooking={vi.fn()} />)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared'
|
||||
import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { isCarrierMode, undatedRides, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { SpillMark } from './nightSpill'
|
||||
import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './useRouteAlternatives'
|
||||
@@ -2025,6 +2025,42 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides the drive leaves out because they are on no day (#2461).
|
||||
*
|
||||
* The map draws such a booking's arc between its terminals all the same, so without this
|
||||
* the ferry looked planned while the rail drove round it by road. One line each, with the
|
||||
* booking a click away, since the fix is a date in the booking itself.
|
||||
*/
|
||||
function UndatedRides({ rides, onOpenBooking }: {
|
||||
rides: readonly Reservation[]
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (!rides.length) return null
|
||||
return (
|
||||
<div role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rides.map(ride => (
|
||||
<li key={ride.id} className="flex items-start gap-2">
|
||||
<AlertTriangle size={13} strokeWidth={2} aria-hidden="true" className="mt-0.5 shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t('roadtrip.ride.undated', { title: ride.title })}</span>
|
||||
{onOpenBooking ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenBooking(ride.id)}
|
||||
className="shrink-0 font-semibold text-content underline underline-offset-2 hover:text-content-secondary"
|
||||
>
|
||||
{t('roadtrip.ride.open')}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The road trip rail: the whole trip as one chain of stops with the driving distance
|
||||
* and time between them.
|
||||
@@ -2057,6 +2093,7 @@ export default function RoadtripSidebar({
|
||||
const numbers = new Map([...routes.days, ...routes.quietDays].map(d => [d.dayId, d.dayNumber]))
|
||||
return (dayId: number): number | null => numbers.get(dayId) ?? null
|
||||
}, [routes.days, routes.quietDays])
|
||||
const undated = useMemo(() => undatedRides(reservations ?? []), [reservations])
|
||||
|
||||
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
|
||||
if (routes.days.length === 0) {
|
||||
@@ -2091,6 +2128,7 @@ export default function RoadtripSidebar({
|
||||
{t(`roadtrip.window.${routes.dayWindowIssue}`, { days: MAX_TRIP_DAYS })}
|
||||
</p>
|
||||
) : null}
|
||||
<UndatedRides rides={undated} onOpenBooking={onOpenBooking} />
|
||||
</div>
|
||||
<div className="roadtrip-rail-scroll flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pb-3.5">
|
||||
{routes.days.map(day => (
|
||||
|
||||
@@ -20,7 +20,8 @@ import { arrivingReroutable, legReroutable, type StopRow } from '../../../../com
|
||||
import { ARRIVING_DRIVE, type RailDrive } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { getDayOrder } from '../../../../utils/dayOrder'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { undatedRides } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The road trip tab: one day of the drive, as a chain or on the map.
|
||||
@@ -102,6 +103,8 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
|
||||
return { atStop, loose: byIndex?.loose ?? [] }
|
||||
}, [stage, planner.reservations, dayOrder])
|
||||
// The rides on no day, which the drive leaves out: listed above the stage, whichever it is.
|
||||
const undated = useMemo(() => undatedRides(planner.reservations), [planner.reservations])
|
||||
// A chip that may not be opened is not a button in the first place (`bookingOpens`),
|
||||
// so nothing is turned away silently here.
|
||||
const canEditBookings = planner.can('reservation_edit', planner.trip)
|
||||
@@ -169,6 +172,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
searchBar ? 'pt-[calc(var(--m-safe-top,12px)+150px)]' : 'pt-[calc(var(--m-safe-top,12px)+102px)]'
|
||||
}`}
|
||||
>
|
||||
<UndatedRides rides={undated} t={t} onOpen={openBooking} />
|
||||
{noDayPicked ? (
|
||||
<PickDay planner={planner} />
|
||||
) : rt.empty || !stage ? (
|
||||
@@ -414,6 +418,37 @@ function UpNext({ planner, shell, rt, stageDayId, onOpen }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides the drive leaves out because they are on no day (#2461), each with its
|
||||
* booking a tap away.
|
||||
*
|
||||
* The map half draws such a booking's arc between its terminals all the same, so without
|
||||
* this the ferry looked planned while the chain went round it by road. Which bookings
|
||||
* these are is decided once, in @trek/shared (`undatedRides`), for the desktop rail too.
|
||||
*/
|
||||
function UndatedRides({ rides, t, onOpen }: {
|
||||
rides: readonly Reservation[]
|
||||
t: TranslationFn
|
||||
onOpen: (res: Reservation) => void
|
||||
}) {
|
||||
if (!rides.length) return null
|
||||
return (
|
||||
<div role="status" className="mb-2.5 rounded-[18px] bg-[color-mix(in_srgb,var(--m-st-pending)_12%,transparent)] px-3.5 py-2.5">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rides.map(ride => (
|
||||
<li key={ride.id} className="flex items-start gap-2 text-caption font-semibold text-[color:var(--m-st-pending)]">
|
||||
<AlertTriangle size={13} strokeWidth={2} aria-hidden="true" className="mt-[2px] shrink-0" />
|
||||
<span className="min-w-0 flex-1">{t('roadtrip.ride.undated', { title: ride.title })}</span>
|
||||
<button type="button" onClick={() => onOpen(ride)} className="shrink-0 text-m-ink underline underline-offset-2">
|
||||
{t('roadtrip.ride.open')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty stage is not a failure.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems, timedSlot } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems, rideSeatKey, timedSlot } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
@@ -304,6 +304,59 @@ describe('getMergedItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
expect(read([newcastle()], [ferry()], [{ id: 10, sort_order: 0 }])).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()
|
||||
})
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { chronoOrder } from '@trek/shared'
|
||||
import { 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.
|
||||
@@ -289,6 +290,38 @@ function applyChronoOrder(
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||
export function getMergedItems(opts: {
|
||||
dayAssignments: any[]
|
||||
@@ -345,11 +378,19 @@ export function getMergedItems(opts: {
|
||||
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 sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { CarrierTerminal, RoadtripDay, RoadtripRoutes, RoadtripStop, RouteS
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-060
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-061
|
||||
//
|
||||
// The stage bar pictures the place its day ends at. It reads that place out of the trip
|
||||
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
|
||||
@@ -916,6 +916,27 @@ describe('MRoadtripTab', () => {
|
||||
expect(p.setShowReservationModal).not.toHaveBeenCalled()
|
||||
expect(shell.openSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-061: a ride on no day is named above the stage and opens in the transport sheet; one on a day is not (#2461)', () => {
|
||||
// The map draws the crossing all the same, so without the line the chain going
|
||||
// round it by road looked like the plan.
|
||||
const terminals = [
|
||||
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879 },
|
||||
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522 },
|
||||
]
|
||||
const reservations = [
|
||||
booking({ id: 70, title: 'IJmuiden to Newcastle', type: 'ferry', day_id: null, endpoints: terminals } as unknown as Partial<Reservation>),
|
||||
booking({ id: 71, title: 'Dated ferry', type: 'ferry', endpoints: terminals } as unknown as Partial<Reservation>),
|
||||
]
|
||||
const { shell } = renderTab(planner({ reservations }))
|
||||
|
||||
const notice = screen.getByText('roadtrip.ride.undated:IJmuiden to Newcastle').closest('[role="status"]') as HTMLElement
|
||||
expect(within(notice).getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(screen.queryByText('roadtrip.ride.undated:Dated ferry')).toBeNull()
|
||||
|
||||
fireEvent.click(within(notice).getByRole('button', { name: 'roadtrip.ride.open' }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 70 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('day swipe', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatDistance,
|
||||
formatDurationShort,
|
||||
seatCarrierStops,
|
||||
undatedRides,
|
||||
viasLeaving,
|
||||
type CarrierBooking,
|
||||
type CarrierSeam,
|
||||
@@ -128,11 +129,35 @@ export class RoadtripPlanService {
|
||||
* reservations graph.
|
||||
*/
|
||||
private carriers(tripId: number): CarrierBooking[] {
|
||||
return this.withTerminals(
|
||||
tripId,
|
||||
this.db.all<CarrierRow>(
|
||||
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
|
||||
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
|
||||
tripId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides the drive leaves out because they are on no day: a flight, train, ferry,
|
||||
* cruise or bus with both terminals located and no day to leave on (#2461). The map
|
||||
* draws their arcs all the same, so the answer names them rather than leaving an
|
||||
* assistant to read a drive around a booked crossing as the plan. Which ones count is
|
||||
* `undatedRides` alone, the rule the planner's rail lists them by, so the statement
|
||||
* names no types of its own.
|
||||
*/
|
||||
undatedRides(tripId: number): { id: number; type: string; title: string }[] {
|
||||
const rows = this.db.all<CarrierRow>(
|
||||
`SELECT id, type, title, day_id, end_day_id, reservation_time, reservation_end_time, metadata, day_plan_position
|
||||
FROM reservations WHERE trip_id = ? AND type IN ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT NULL`,
|
||||
FROM reservations WHERE trip_id = ? AND day_id IS NULL`,
|
||||
tripId,
|
||||
);
|
||||
return undatedRides(this.withTerminals(tripId, rows)).map(({ id, type, title }) => ({ id, type, title }));
|
||||
}
|
||||
|
||||
/** The rows with their terminals and the slots the day plan gave them joined on. */
|
||||
private withTerminals(tripId: number, rows: CarrierRow[]): CarrierBooking[] {
|
||||
if (!rows.length) return [];
|
||||
const endpoints = this.db.all<{
|
||||
reservation_id: number;
|
||||
@@ -359,6 +384,7 @@ export class RoadtripPlanService {
|
||||
calculated,
|
||||
failures,
|
||||
omittedVisits: context.visits.filter((v) => v.lat === null || v.lng === null).map((v) => v.id),
|
||||
undatedRides: this.undatedRides(tripId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class RoadtripPlanningMcp {
|
||||
@Tool({
|
||||
name: 'calculate_roadtrip',
|
||||
description:
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. A flight, train, ferry, cruise or bus booking with located terminals is a seam: its terminals appear as stops carrying `carrier` (role departure or arrival), the road ends at the departure terminal and resumes at the arrival one, and the leg between them is the ride, in the mode of the booking type, with its timetable minutes and no distance. A hire car booking with a located pick-up desk puts that desk on the road as a stop carrying `carrier` with role pickup, and its return desk with role return when the booking names a return day and place; the road runs through both. A flight, train, ferry, cruise or bus booking on no day is not on the drive at all, although its terminals are located: undatedRides lists each one (id, type, title) so it can be given a date with the reservation tools, after which the road ends at its terminal. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||
inputSchema: roadtripPlanRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
@@ -64,6 +64,7 @@ export class RoadtripPlanningMcp {
|
||||
settings: plan.preferences,
|
||||
failures: plan.failures,
|
||||
omittedVisitIds: plan.omittedVisits,
|
||||
undatedRides: plan.undatedRides,
|
||||
dayWindowIssue: calculated.dayWindowIssue,
|
||||
totalDistanceMetres: calculated.totalDistance,
|
||||
totalDurationSeconds: calculated.totalDuration,
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* column the statement never selects would go unnoticed there. These run the statement.
|
||||
*/
|
||||
import { db } from '../../../src/db/database';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
|
||||
import { createDay, createDayAccommodation, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
@@ -177,6 +179,10 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
it('seats the terminals in the day and never asks the router for the ride', async () => {
|
||||
const { user, trip, visits, plans } = setup();
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id);
|
||||
// Pinned to the minute the drive reaches it anyway, so the clock alone seats the
|
||||
// flight here. Untimed, Celle lies south of Lueneburg with the airport north of both,
|
||||
// and the flight would go where it adds the least road (#2461, see below).
|
||||
db.prepare("UPDATE day_assignments SET assignment_time = '15:30' WHERE id = ?").run(visits[2].id);
|
||||
const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number };
|
||||
const flightId = withFlight(day.day_id, trip.id);
|
||||
|
||||
@@ -185,7 +191,7 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
expect(context.carriers).toHaveLength(1);
|
||||
expect(context.carriers[0].endpoints).toHaveLength(2);
|
||||
const card = calculated.days[0];
|
||||
// Behind Lueneburg (10:00), before the untimed Celle: the day plan's own seat.
|
||||
// Behind Lueneburg (10:00), before Celle (15:30): the day plan's own seat.
|
||||
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Hamburg', 'Lueneburg', 'departure', 'arrival', 'Celle']);
|
||||
const departure = card.stops[2];
|
||||
expect(departure.carrier).toMatchObject({ reservationId: flightId, type: 'flight', code: 'HAM', at: '13:20' });
|
||||
@@ -266,3 +272,89 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
expect(calculated.totalStops).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The crossing from the report behind #2461: Amsterdam and Newcastle on one day, neither
|
||||
* with a clock, and a ferry from IJmuiden to the Port of Tyne. A day sailing here, so the
|
||||
* drive on from the far pier ends before midnight and the one-day trip stays one card.
|
||||
*/
|
||||
describe('a ferry across the day, and one on no day (#2461)', () => {
|
||||
function crossing() {
|
||||
const { user } = createUser(db);
|
||||
const trip = createTrip(db, user.id);
|
||||
const day = createDay(db, trip.id);
|
||||
for (const [name, lat, lng] of [
|
||||
['Amsterdam', 52.3731, 4.8926],
|
||||
['Newcastle', 54.9783, -1.6178],
|
||||
] as const) {
|
||||
createDayAssignment(db, day.id, createPlace(db, trip.id, { name, lat, lng }).id);
|
||||
}
|
||||
const router = hourlyRouter();
|
||||
const plans = new RoadtripPlanService(
|
||||
new DatabaseService(db),
|
||||
{ getUserSettings: () => ({}) } as never,
|
||||
{ read: () => ({}) } as never,
|
||||
router as never,
|
||||
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
|
||||
{ list: () => [] } as never,
|
||||
);
|
||||
return { user, trip, day, router, plans };
|
||||
}
|
||||
|
||||
function withFerry(tripId: number, dayId: number | null, title: string, located = true): number {
|
||||
const id = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time)
|
||||
VALUES (?, ?, 'ferry', ?, ?, '09:30', '15:00')`,
|
||||
)
|
||||
.run(tripId, title, dayId, dayId).lastInsertRowid,
|
||||
);
|
||||
if (located) {
|
||||
const endpoint = db.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
endpoint.run(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879);
|
||||
endpoint.run(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
it('drives to the pier and on from the far one, and never overland between the two shores', async () => {
|
||||
const { user, trip, day, router, plans } = crossing();
|
||||
withFerry(trip.id, day.id, 'IJmuiden to Newcastle');
|
||||
|
||||
const { calculated, undatedRides } = await plans.calculate(trip.id, user.id);
|
||||
|
||||
const card = calculated.days[0];
|
||||
// By the clock alone the ferry closed the day: Amsterdam to Newcastle overland, back
|
||||
// to IJmuiden, and only then the crossing.
|
||||
expect(card.stops.map((s) => s.carrier?.role ?? s.name)).toEqual(['Amsterdam', 'departure', 'arrival', 'Newcastle']);
|
||||
expect(card.legs.map((l) => l?.mode)).toEqual(['driving', 'ferry', 'driving']);
|
||||
// Two roads, one to each side of the crossing, and nothing between the two cities.
|
||||
expect(router.route).toHaveBeenCalledTimes(2);
|
||||
expect(card.distance).toBe(120_000);
|
||||
expect(undatedRides).toEqual([]);
|
||||
});
|
||||
|
||||
it('names a located ride on no day, and the tool answers with the same list', async () => {
|
||||
const { user, trip, day, plans } = crossing();
|
||||
const undated = withFerry(trip.id, null, 'Ferry without a date');
|
||||
withFerry(trip.id, day.id, 'Ferry on the day');
|
||||
withFerry(trip.id, null, 'Ferry without terminals', false);
|
||||
db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'restaurant')").run(trip.id);
|
||||
|
||||
const plan = await plans.calculate(trip.id, user.id);
|
||||
|
||||
expect(plan.undatedRides).toEqual([{ id: undated, type: 'ferry', title: 'Ferry without a date' }]);
|
||||
// It is on the drive nowhere, which is why it is named.
|
||||
expect(plan.context.carriers.map((c) => c.id)).not.toContain(undated);
|
||||
expect(plan.calculated.days.flatMap((d) => d.stops).some((s) => s.carrier?.reservationId === undated)).toBe(false);
|
||||
|
||||
const tool = new RoadtripPlanningMcp(plans, {} as never, {} as never);
|
||||
const answer = await tool.calculate({ tripId: trip.id, includeGeometry: false }, { userId: user.id } as McpContext);
|
||||
const body = JSON.parse(answer.content[0].text as string);
|
||||
expect(body.undatedRides).toEqual(plan.undatedRides);
|
||||
expect(body.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,29 @@ describe('browser-independent roadtrip calculation', () => {
|
||||
expect(day).not.toHaveProperty('legLines');
|
||||
expect(day).not.toHaveProperty('arrivingLine');
|
||||
});
|
||||
it('answers calculate_roadtrip with the rides on no day, an empty list when there are none (#2461)', async () => {
|
||||
// Additive: the rest of the answer is what it was. The rides themselves are read off the
|
||||
// real tables in roadtrip-plan.service.test.ts; this pins the field and the words for it.
|
||||
const s = setup();
|
||||
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
|
||||
const body = JSON.parse((await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx)).content[0].text as string);
|
||||
expect(body.undatedRides).toEqual([]);
|
||||
expect(body.days.length).toBeGreaterThan(0);
|
||||
// And the tool says what the field is, or an assistant has no reason to read it.
|
||||
const addons = { isAddonEnabled: vi.fn(() => true) };
|
||||
const registry = createTestRegistry([new RoadtripPlanningMcp(s.plans, {} as never, addons as never)], {
|
||||
accessPolicy: trekMcpAccessPolicy,
|
||||
validateAccess: trekMcpValidateAccess,
|
||||
});
|
||||
const described = new Map<string, string>();
|
||||
const registrar = {
|
||||
registerTool: (name: string, config: { description?: string }) => {
|
||||
described.set(name, config.description ?? '');
|
||||
},
|
||||
};
|
||||
registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
|
||||
expect(described.get('calculate_roadtrip')).toContain('undatedRides');
|
||||
});
|
||||
it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => {
|
||||
const s = setup();
|
||||
s.visits[0].end_day = 1;
|
||||
|
||||
@@ -274,6 +274,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'الاستلام {time}',
|
||||
'roadtrip.ride.return': 'الإرجاع {time}',
|
||||
'roadtrip.ride.open': 'فتح الحجز',
|
||||
'roadtrip.ride.undated': 'لا يوجد تاريخ للحجز {title}، لذلك لا يستخدمه المسار.',
|
||||
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -280,6 +280,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Retirada {time}',
|
||||
'roadtrip.ride.return': 'Devolução {time}',
|
||||
'roadtrip.ride.open': 'Abrir reserva',
|
||||
'roadtrip.ride.undated': 'A reserva {title} está sem data, por isso o percurso não a considera.',
|
||||
'roadtrip.bookings.loose': 'Também reservado neste dia',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -283,6 +283,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Recollida {time}',
|
||||
'roadtrip.ride.return': 'Devolució {time}',
|
||||
'roadtrip.ride.open': 'Obre la reserva',
|
||||
'roadtrip.ride.undated': 'La reserva {title} no té data, així que el trajecte no la té en compte.',
|
||||
'roadtrip.bookings.loose': 'També reservat aquest dia',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Vyzvednutí {time}',
|
||||
'roadtrip.ride.return': 'Vrácení {time}',
|
||||
'roadtrip.ride.open': 'Otevřít rezervaci',
|
||||
'roadtrip.ride.undated': 'Rezervace {title} nemá datum, proto s ní jízda nepočítá.',
|
||||
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -277,6 +277,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Abholung {time}',
|
||||
'roadtrip.ride.return': 'Rückgabe {time}',
|
||||
'roadtrip.ride.open': 'Buchung öffnen',
|
||||
'roadtrip.ride.undated': 'Die Buchung {title} hat kein Datum, deshalb plant die Fahrt nicht mit ihr.',
|
||||
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -275,6 +275,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Pick-up {time}',
|
||||
'roadtrip.ride.return': 'Return {time}',
|
||||
'roadtrip.ride.open': 'Open booking',
|
||||
'roadtrip.ride.undated': '{title} has no date, so the drive does not use it.',
|
||||
'roadtrip.bookings.loose': 'Also booked this day',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -282,6 +282,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Recogida {time}',
|
||||
'roadtrip.ride.return': 'Devolución {time}',
|
||||
'roadtrip.ride.open': 'Abrir reserva',
|
||||
'roadtrip.ride.undated': 'La reserva {title} no tiene fecha, así que el trayecto no la tiene en cuenta.',
|
||||
'roadtrip.bookings.loose': 'También reservado este día',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -283,6 +283,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Prise en charge {time}',
|
||||
'roadtrip.ride.return': 'Restitution {time}',
|
||||
'roadtrip.ride.open': 'Ouvrir la réservation',
|
||||
'roadtrip.ride.undated': 'La réservation {title} n’a pas de date, le trajet n’en tient donc pas compte.',
|
||||
'roadtrip.bookings.loose': 'Également réservé ce jour',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -284,6 +284,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Παραλαβή {time}',
|
||||
'roadtrip.ride.return': 'Επιστροφή {time}',
|
||||
'roadtrip.ride.open': 'Άνοιγμα κράτησης',
|
||||
'roadtrip.ride.undated': 'Η κράτηση {title} δεν έχει ημερομηνία, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
|
||||
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -279,6 +279,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Felvétel {time}',
|
||||
'roadtrip.ride.return': 'Visszaadás {time}',
|
||||
'roadtrip.ride.open': 'Foglalás megnyitása',
|
||||
'roadtrip.ride.undated': '{title}: a foglalásnak nincs dátuma, ezért az útvonal nem számol vele.',
|
||||
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -282,6 +282,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Pengambilan {time}',
|
||||
'roadtrip.ride.return': 'Pengembalian {time}',
|
||||
'roadtrip.ride.open': 'Buka pemesanan',
|
||||
'roadtrip.ride.undated': 'Pemesanan {title} tidak memiliki tanggal, jadi perjalanan tidak memperhitungkannya.',
|
||||
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -282,6 +282,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Ritiro {time}',
|
||||
'roadtrip.ride.return': 'Riconsegna {time}',
|
||||
'roadtrip.ride.open': 'Apri prenotazione',
|
||||
'roadtrip.ride.undated': 'La prenotazione {title} non ha una data, quindi il percorso non la considera.',
|
||||
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -277,6 +277,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': '受取 {time}',
|
||||
'roadtrip.ride.return': '返却 {time}',
|
||||
'roadtrip.ride.open': '予約を開く',
|
||||
'roadtrip.ride.undated': '{title} には日付がないため、走行では使われません。',
|
||||
'roadtrip.bookings.loose': 'この日のその他の予約',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -274,6 +274,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': '픽업 {time}',
|
||||
'roadtrip.ride.return': '반납 {time}',
|
||||
'roadtrip.ride.open': '예약 열기',
|
||||
'roadtrip.ride.undated': '{title}에 날짜가 없어 주행에 반영되지 않습니다.',
|
||||
'roadtrip.bookings.loose': '이날의 다른 예약',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -281,6 +281,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Ophalen {time}',
|
||||
'roadtrip.ride.return': 'Inleveren {time}',
|
||||
'roadtrip.ride.open': 'Boeking openen',
|
||||
'roadtrip.ride.undated': 'De boeking {title} heeft geen datum, dus de rit houdt er geen rekening mee.',
|
||||
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -279,6 +279,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Odbiór {time}',
|
||||
'roadtrip.ride.return': 'Zwrot {time}',
|
||||
'roadtrip.ride.open': 'Otwórz rezerwację',
|
||||
'roadtrip.ride.undated': 'Rezerwacja {title} nie ma daty, więc trasa jej nie uwzględnia.',
|
||||
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -281,6 +281,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Получение {time}',
|
||||
'roadtrip.ride.return': 'Возврат {time}',
|
||||
'roadtrip.ride.open': 'Открыть бронирование',
|
||||
'roadtrip.ride.undated': 'У бронирования {title} нет даты, поэтому маршрут его не учитывает.',
|
||||
'roadtrip.bookings.loose': 'Также забронировано в этот день',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Upphämtning {time}',
|
||||
'roadtrip.ride.return': 'Återlämning {time}',
|
||||
'roadtrip.ride.open': 'Öppna bokning',
|
||||
'roadtrip.ride.undated': 'Bokningen {title} har inget datum, så körningen tar inte med den.',
|
||||
'roadtrip.bookings.loose': 'Även bokat denna dag',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -280,6 +280,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Alış {time}',
|
||||
'roadtrip.ride.return': 'İade {time}',
|
||||
'roadtrip.ride.open': 'Rezervasyonu aç',
|
||||
'roadtrip.ride.undated': '{title} rezervasyonunun tarihi yok, bu yüzden sürüş onu hesaba katmıyor.',
|
||||
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -280,6 +280,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Отримання {time}',
|
||||
'roadtrip.ride.return': 'Повернення {time}',
|
||||
'roadtrip.ride.open': 'Відкрити бронювання',
|
||||
'roadtrip.ride.undated': 'Бронювання {title} не має дати, тому маршрут його не враховує.',
|
||||
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': 'Nhận xe {time}',
|
||||
'roadtrip.ride.return': 'Trả xe {time}',
|
||||
'roadtrip.ride.open': 'Mở đặt chỗ',
|
||||
'roadtrip.ride.undated': 'Đặt chỗ {title} chưa có ngày, nên hành trình không tính đến nó.',
|
||||
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -265,6 +265,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': '取車 {time}',
|
||||
'roadtrip.ride.return': '還車 {time}',
|
||||
'roadtrip.ride.open': '開啟預訂',
|
||||
'roadtrip.ride.undated': '{title} 沒有日期,因此行程不會用到它。',
|
||||
'roadtrip.bookings.loose': '當天的其他預訂',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -266,6 +266,7 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.pickup': '取车 {time}',
|
||||
'roadtrip.ride.return': '还车 {time}',
|
||||
'roadtrip.ride.open': '打开预订',
|
||||
'roadtrip.ride.undated': '{title} 没有日期,因此行程不会用到它。',
|
||||
'roadtrip.bookings.loose': '当天的其他预订',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* ROADTRIP-CARRIERS-001..021: a booking the traveller rides becomes a seam in the drive,
|
||||
* ROADTRIP-CARRIERS-001..027: a booking the traveller rides becomes a seam in the drive,
|
||||
* and a hire car puts its desks on it.
|
||||
*
|
||||
* The road ends at the terminal the ride leaves from and starts again at the one it
|
||||
* lands at (#2428). Pinned here: which bookings count, where their terminals are seated
|
||||
* among the day's stops, what the ride between them is worth to the chain, that
|
||||
* nothing about a terminal reads as a stored stop, and that a hire car's pick-up and
|
||||
* return are points the road runs through rather than a seam in it.
|
||||
* return are points the road runs through rather than a seam in it. From 022 on: a ride
|
||||
* on no day is named rather than dropped, and a ride within one day is seated where it
|
||||
* adds the least road, as far as the clock leaves a choice (#2461).
|
||||
*/
|
||||
import { assembleRoadtrip } from './assemble';
|
||||
import {
|
||||
@@ -19,10 +21,15 @@ import {
|
||||
carriesTheCar,
|
||||
isCarrierMode,
|
||||
isPickupStop,
|
||||
isUndatedRide,
|
||||
rideSeatAfter,
|
||||
sameDayRide,
|
||||
seatCarrierStops,
|
||||
terminalAssignmentId,
|
||||
undatedRides,
|
||||
viasLeaving,
|
||||
type CarrierBooking,
|
||||
type SeatItem,
|
||||
} from './carriers';
|
||||
import { planDayWindow } from './dayWindow';
|
||||
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
@@ -618,3 +625,153 @@ describe('the ride in the drive', () => {
|
||||
expect(routes.days.some((d) => d.spills?.length)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The crossing from the report behind #2461: Amsterdam and Newcastle on one day, neither
|
||||
* with a clock, and the ferry from IJmuiden to the Port of Tyne between them.
|
||||
*/
|
||||
const AMSTERDAM = { lat: 52.3731, lng: 4.8926 };
|
||||
const NEWCASTLE = { lat: 54.9783, lng: -1.6178 };
|
||||
const IJMUIDEN = { lat: 52.4581, lng: 4.5879 };
|
||||
const PORT_OF_TYNE = { lat: 54.9925, lng: -1.4522 };
|
||||
|
||||
const ferry = (over: Partial<CarrierBooking> = {}): CarrierBooking => ({
|
||||
id: 69,
|
||||
type: 'ferry',
|
||||
title: 'IJmuiden to Newcastle',
|
||||
day_id: 1,
|
||||
end_day_id: 1,
|
||||
reservation_time: '2026-10-06T17:30',
|
||||
reservation_end_time: '2026-10-06T23:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'IJmuiden', code: null, ...IJMUIDEN },
|
||||
{ role: 'to', sequence: 1, name: 'Port of Tyne', code: null, ...PORT_OF_TYNE },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
|
||||
const crossingDay = (newcastleAt: string | null = null): RoadtripStop[] => [
|
||||
stop({ ownerIndex: 0, name: 'Amsterdam', ...AMSTERDAM }),
|
||||
stop({ ownerIndex: 1, name: 'Newcastle', ...NEWCASTLE, time: newcastleAt }),
|
||||
];
|
||||
|
||||
const order = (stops: RoadtripStop[]): string[] => stops.map((s) => s.carrier?.role ?? s.name);
|
||||
|
||||
describe('a ride on no day (#2461)', () => {
|
||||
it('ROADTRIP-CARRIERS-022: a located ride without a day is named, one on a day or without both terminals is not', () => {
|
||||
const undated = ferry({ id: 71, day_id: null, end_day_id: null });
|
||||
expect(isUndatedRide(undated)).toBe(true);
|
||||
// No seam either: that is the silence the name is for.
|
||||
expect(carrierSeam(undated)).toBeNull();
|
||||
expect(isUndatedRide(ferry())).toBe(false);
|
||||
expect(isUndatedRide(ferry({ day_id: null, endpoints: [] }))).toBe(false);
|
||||
expect(isUndatedRide(ferry({ day_id: null, endpoints: [ferry().endpoints![0]!] }))).toBe(false);
|
||||
// A hire car is no ride, and a leg that names its day puts the booking on that day.
|
||||
expect(isUndatedRide(ferry({ day_id: null, type: 'car' }))).toBe(false);
|
||||
const legs = JSON.stringify({ legs: [{ dep_day_id: 2, dep_time: '10:00', arr_day_id: 2, arr_time: '11:00' }] });
|
||||
expect(isUndatedRide(flight({ day_id: null, metadata: legs }))).toBe(false);
|
||||
// Listed by id, whatever order the bookings come in.
|
||||
const listed = undatedRides([ferry({ id: 9, day_id: null }), ferry(), ferry({ id: 3, day_id: null })]);
|
||||
expect(listed.map((b) => b.id)).toEqual([3, 9]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a ride within one day, seated by where it goes (#2461)', () => {
|
||||
it('ROADTRIP-CARRIERS-023: only a ride that lands on the day it left, located at both ends and without a change, has a seat of its own', () => {
|
||||
expect(sameDayRide(ferry())).toEqual({ minutes: 17 * 60 + 30, from: IJMUIDEN, to: PORT_OF_TYNE });
|
||||
expect(sameDayRide(ferry({ reservation_time: null }))!.minutes).toBeNull();
|
||||
expect(sameDayRide(ferry({ end_day_id: 2 }))).toBeNull();
|
||||
expect(sameDayRide(ferry({ endpoints: [] }))).toBeNull();
|
||||
expect(sameDayRide(ferry({ type: 'car' }))).toBeNull();
|
||||
const stopover = flight({
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '09:00' },
|
||||
{ dep_day_id: 1, dep_time: '10:00', arr_day_id: 1, arr_time: '11:00' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(carrierSeam(stopover)!.stopover).toBe(true);
|
||||
expect(carrierSeam(flight())!.stopover).toBe(false);
|
||||
expect(sameDayRide(stopover)).toBeNull();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-024: a ferry between two untimed stops on its two shores sits between them, not at the end of the day', () => {
|
||||
const seated = seatCarrierStops(1, crossingDay(), [0, 1], [carrierSeam(ferry())!]);
|
||||
expect(order(seated)).toEqual(['Amsterdam', 'departure', 'arrival', 'Newcastle']);
|
||||
// The terminals still stand for the stop that follows them, the way they always have.
|
||||
expect(seated[1]!.ownerIndex).toBe(1);
|
||||
// Newcastle alone on the day: the crossing opens it, and the drive starts at the far pier.
|
||||
const newcastleOnly = [stop({ ownerIndex: 0, name: 'Newcastle', ...NEWCASTLE })];
|
||||
expect(order(seatCarrierStops(1, newcastleOnly, [0], [carrierSeam(ferry())!]))).toEqual([
|
||||
'departure',
|
||||
'arrival',
|
||||
'Newcastle',
|
||||
]);
|
||||
// Without a clock it goes there too: nothing timed binds it from above.
|
||||
const unclocked = carrierSeam(ferry({ reservation_time: null, reservation_end_time: null }))!;
|
||||
expect(order(seatCarrierStops(1, crossingDay(), [0, 1], [unclocked]))).toEqual([
|
||||
'Amsterdam',
|
||||
'departure',
|
||||
'arrival',
|
||||
'Newcastle',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-025: a clock still binds: a stop timed before the departure keeps the ride behind it, and a dragged slot wins', () => {
|
||||
const timed = seatCarrierStops(1, crossingDay('10:00'), [0, 1], [carrierSeam(ferry())!]);
|
||||
expect(order(timed)).toEqual(['Amsterdam', 'Newcastle', 'departure', 'arrival']);
|
||||
const dragged = carrierSeam(ferry({ day_positions: { '1': 5 } }))!;
|
||||
expect(order(seatCarrierStops(1, crossingDay(), [0, 1], [dragged]))).toEqual([
|
||||
'Amsterdam',
|
||||
'Newcastle',
|
||||
'departure',
|
||||
'arrival',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-026: between the clocks, the seat that adds the fewest kilometres, the start of the day among them', () => {
|
||||
const ride = { minutes: 17 * 60 + 30, from: IJMUIDEN, to: PORT_OF_TYNE };
|
||||
const item = (key: number, point: SeatItem['point']): SeatItem => ({ key, minutes: null, point });
|
||||
expect(rideSeatAfter([item(0, AMSTERDAM), item(1, NEWCASTLE)], ride)).toBe(0);
|
||||
// Newcastle alone: the ride opens the day and lands next door. Amsterdam alone: the
|
||||
// clock's seat at the end of the day is already the short drive to the pier.
|
||||
expect(rideSeatAfter([item(0, NEWCASTLE)], ride)).toBe(-Infinity);
|
||||
expect(rideSeatAfter([item(0, AMSTERDAM)], ride)).toBeNull();
|
||||
// An item without a point is passed over: the kilometres are measured between the
|
||||
// located ones around it, and of two equal seats the earlier one is taken.
|
||||
expect(rideSeatAfter([item(0, AMSTERDAM), item(1, null), item(2, NEWCASTLE)], ride)).toBe(0);
|
||||
// Keys, not the order the items come in.
|
||||
expect(rideSeatAfter([item(7, NEWCASTLE), item(3, AMSTERDAM)], ride)).toBe(3);
|
||||
// Nothing located, or no choice the clock leaves open: the clock's own seat.
|
||||
expect(rideSeatAfter([item(0, null), item(1, null)], ride)).toBeNull();
|
||||
expect(rideSeatAfter([{ key: 0, minutes: 10 * 60, point: AMSTERDAM }], ride)).toBeNull();
|
||||
expect(rideSeatAfter([], ride)).toBeNull();
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-027: the first item timed after the departure is a wall, and a seat has to beat the clock by a margin', () => {
|
||||
// A ride north along one meridian. By the kilometres it belongs behind the last stop,
|
||||
// where it starts next door, but the 20:00 stop comes after a 17:30 departure and
|
||||
// holds it in front: of the seats left, the start of the day is the shorter drive.
|
||||
const north = { minutes: 17 * 60 + 30, from: { lat: 10.1, lng: 0 }, to: { lat: 20, lng: 0 } };
|
||||
const walled: SeatItem[] = [
|
||||
{ key: 0, minutes: null, point: { lat: 0, lng: 0 } },
|
||||
{ key: 1, minutes: 20 * 60, point: { lat: 1, lng: 0 } },
|
||||
{ key: 2, minutes: null, point: { lat: 10, lng: 0 } },
|
||||
];
|
||||
expect(rideSeatAfter(walled, north)).toBe(-Infinity);
|
||||
// Without the wall the ride closes the day, which is where the clock puts it anyway.
|
||||
const unwalled = walled.map((w) => ({ ...w, minutes: null }));
|
||||
expect(rideSeatAfter(unwalled, north)).toBeNull();
|
||||
// Two seats a couple of kilometres apart on a crossing of six hundred: straight lines
|
||||
// cannot tell them apart, so the clock's seat stands. The flight of 008 shows the same
|
||||
// in the drive, where Stop 2 stays behind the ride.
|
||||
const close: SeatItem[] = [
|
||||
{ key: 0, minutes: 10 * 60, point: { lat: 50.1, lng: 10 } },
|
||||
{ key: 1, minutes: null, point: { lat: 50.2, lng: 10 } },
|
||||
{ key: 2, minutes: 17 * 60, point: { lat: 50.3, lng: 10 } },
|
||||
];
|
||||
const muc = { minutes: 13 * 60 + 20, from: { lat: 48.35, lng: 11.78 }, to: { lat: 53.63, lng: 9.99 } };
|
||||
expect(rideSeatAfter(close, muc)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+207
-28
@@ -1,4 +1,5 @@
|
||||
import { chronoOrder } from '../day/chrono-order';
|
||||
import { haversineKm, type LatLng } from './corridor';
|
||||
import type { CarrierTerminal, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import { formatClock, formatDurationShort, parseClock } from './roadtripModel';
|
||||
|
||||
@@ -99,6 +100,11 @@ export interface CarrierSeam {
|
||||
title: string;
|
||||
/** A ride seams the drive; a rental only puts its two desks on it. */
|
||||
kind: 'ride' | 'rental';
|
||||
/**
|
||||
* Whether the ride changes vehicle on the way. The day plan lists each leg of such a
|
||||
* booking by its own clock, so the seat by geography (`rideSeatAfter`) leaves it alone.
|
||||
*/
|
||||
stopover: boolean;
|
||||
departure: CarrierEnd;
|
||||
/**
|
||||
* A ride always has both ends. A hire car has a return only when the booking says
|
||||
@@ -149,6 +155,44 @@ function positionOn(positions: Record<string, number> | null | undefined, dayId:
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
type Endpoint = NonNullable<CarrierBooking['endpoints']>[number];
|
||||
type LocatedEndpoint = Endpoint & { lat: number; lng: number };
|
||||
|
||||
function isLocated(e: Endpoint): e is LocatedEndpoint {
|
||||
return typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminals a booking stops and resumes the drive at, or null when it has not the
|
||||
* ones its kind needs.
|
||||
*
|
||||
* A ride needs both ends to say where the drive stops and where it resumes, and takes
|
||||
* the first and the last located stop for them when no endpoint is marked. A hire car's
|
||||
* desks are only ever the ones marked as such: guessing one would put a stop on the
|
||||
* road nobody booked, and a return desk read as the pick-up starts the drive where it
|
||||
* ends. The same desk twice is fine for a car, and no seam for a ride.
|
||||
*/
|
||||
function seamEnds(
|
||||
booking: CarrierBooking,
|
||||
kind: CarrierSeam['kind'],
|
||||
): { from: LocatedEndpoint; to: LocatedEndpoint | undefined } | null {
|
||||
const located = (booking.endpoints ?? []).filter(isLocated).sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
const from =
|
||||
kind === 'ride' ? (located.find((e) => e.role === 'from') ?? located[0]) : located.find((e) => e.role === 'from');
|
||||
const to =
|
||||
kind === 'ride'
|
||||
? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1])
|
||||
: [...located].reverse().find((e) => e.role === 'to');
|
||||
if (!from) return null;
|
||||
if (kind === 'ride' && (!to || from === to)) return null;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** The day a booking leaves on: its first leg's, else its own. Null when it names none. */
|
||||
function departureDayOf(booking: CarrierBooking, legs: readonly LegRecord[]): number | null {
|
||||
return legs[0]?.dep_day_id ?? booking.day_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam a booking makes, or null when it makes none: a booking on no day is not on the
|
||||
* plan, and one without a located terminal at BOTH ends cannot say where the drive stops
|
||||
@@ -164,29 +208,14 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
? 'rental'
|
||||
: null;
|
||||
if (!kind) return null;
|
||||
const located = (booking.endpoints ?? [])
|
||||
.filter(
|
||||
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
|
||||
)
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
// A ride needs both ends to say where the drive stops and where it resumes, and takes
|
||||
// the first and the last located stop for them when no endpoint is marked. A hire car's
|
||||
// desks are only ever the ones marked as such: guessing one would put a stop on the
|
||||
// road nobody booked, and a return desk read as the pick-up starts the drive where it
|
||||
// ends. The same desk twice is fine for a car, and no seam for a ride.
|
||||
const from =
|
||||
kind === 'ride' ? (located.find((e) => e.role === 'from') ?? located[0]) : located.find((e) => e.role === 'from');
|
||||
const to =
|
||||
kind === 'ride'
|
||||
? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1])
|
||||
: [...located].reverse().find((e) => e.role === 'to');
|
||||
if (!from) return null;
|
||||
if (kind === 'ride' && (!to || from === to)) return null;
|
||||
const ends = seamEnds(booking, kind);
|
||||
if (!ends) return null;
|
||||
const { from, to } = ends;
|
||||
|
||||
const legs = legsOf(booking);
|
||||
const first = legs[0];
|
||||
const last = legs[legs.length - 1];
|
||||
const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
|
||||
const depDayId = departureDayOf(booking, legs);
|
||||
if (depDayId == null) return null;
|
||||
// A ride lands on the day it left unless the booking says otherwise. A hire car is
|
||||
// handed back on the day the booking names, and on no day when it names none.
|
||||
@@ -202,14 +231,15 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
type: booking.type,
|
||||
title: booking.title,
|
||||
kind,
|
||||
stopover: legs.length > 1,
|
||||
departure: {
|
||||
dayId: depDayId,
|
||||
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
|
||||
clock: depClock,
|
||||
name: from.name,
|
||||
code: from.code ?? null,
|
||||
lat: from.lat!,
|
||||
lng: from.lng!,
|
||||
lat: from.lat,
|
||||
lng: from.lng,
|
||||
},
|
||||
arrival:
|
||||
to && arrDayId != null
|
||||
@@ -219,13 +249,148 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
clock: arrClock,
|
||||
name: to.name,
|
||||
code: to.code ?? null,
|
||||
lat: to.lat!,
|
||||
lng: to.lng!,
|
||||
lat: to.lat,
|
||||
lng: to.lng,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking is a ride the drive would seam at its terminals if it were on a day:
|
||||
* a flight, train, ferry, cruise or bus with both ends located and no day to leave on.
|
||||
*
|
||||
* Such a booking falls out of the road trip without a word, while the map still draws
|
||||
* its arc between the same two terminals, so it looks planned and the drive goes round
|
||||
* it by road (#2461). A booking gets there by being saved without a date, or by losing
|
||||
* its day when the trip was shortened.
|
||||
*/
|
||||
export function isUndatedRide(booking: CarrierBooking): boolean {
|
||||
return (
|
||||
isCarrierType(booking.type) &&
|
||||
seamEnds(booking, 'ride') !== null &&
|
||||
departureDayOf(booking, legsOf(booking)) == null
|
||||
);
|
||||
}
|
||||
|
||||
/** The trip's rides on no day (`isUndatedRide`), by id so both sides list them alike. */
|
||||
export function undatedRides<T extends CarrierBooking>(bookings: readonly T[]): T[] {
|
||||
return bookings.filter(isUndatedRide).sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
/** One item of a day, as the seat of a ride reads it. */
|
||||
export interface SeatItem {
|
||||
/** Where it sorts in the day: its stored order index, or the day list's own key. */
|
||||
key: number;
|
||||
/** Its own clock in minutes, null without one. */
|
||||
minutes: number | null;
|
||||
/** Where the drive goes for it; null for an item it does not drive to. */
|
||||
point: LatLng | null;
|
||||
}
|
||||
|
||||
/** A ride that leaves and lands on one day, as far as its seat among the stops goes. */
|
||||
export interface SameDayRide {
|
||||
/** The departure's clock in minutes, null when the booking names none. */
|
||||
minutes: number | null;
|
||||
from: LatLng;
|
||||
to: LatLng;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much less road a seat has to promise than the clock's before it wins, as a share of
|
||||
* the crossing's own length.
|
||||
*
|
||||
* The kilometres are straight lines, a guess at the road. Two seats that differ by less
|
||||
* than a quarter of the crossing are within that guess, and then the clock's seat stands:
|
||||
* it is the one the day plan has always shown. The seat this is for saves about twice the
|
||||
* crossing, the drive overland to the far shore and back to the pier.
|
||||
*/
|
||||
const SEAT_MARGIN_SHARE = 0.25;
|
||||
|
||||
function sameDayRideOf(seam: CarrierSeam): SameDayRide | null {
|
||||
if (seam.kind !== 'ride' || seam.stopover || seam.arrival?.dayId !== seam.departure.dayId) return null;
|
||||
return {
|
||||
minutes: parseClock(seam.departure.clock),
|
||||
from: { lat: seam.departure.lat, lng: seam.departure.lng },
|
||||
to: { lat: seam.arrival.lat, lng: seam.arrival.lng },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ride a booking makes within one day, for seating it by where it goes: a flight,
|
||||
* train, ferry, cruise or bus that leaves and lands on the same day, with both terminals
|
||||
* located and no change of vehicle. Null for anything else, which keeps the clock's seat.
|
||||
*/
|
||||
export function sameDayRide(booking: CarrierBooking): SameDayRide | null {
|
||||
const seam = carrierSeam(booking);
|
||||
return seam ? sameDayRideOf(seam) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a ride that leaves and lands on the same day goes when nobody placed it, if not
|
||||
* where the clock puts it: the key of the item it goes behind, -Infinity to open the day,
|
||||
* or null to keep the clock's seat.
|
||||
*
|
||||
* The clock alone files a ride behind the last stop timed at or before its departure,
|
||||
* and at the end of the day when there is none. A ferry between two untimed stops, one
|
||||
* on each shore, then closed the day: the drive went overland to the far shore, back to
|
||||
* the pier, and only then across (#2461). The clock still bounds the seat, behind the
|
||||
* last item timed at or before the departure and ahead of the first one timed after it.
|
||||
* Between those the seat is the one that adds the fewest straight-line kilometres: the
|
||||
* drive to the departure terminal and the drive on from the arrival one, less the drive
|
||||
* the ride stands in for. At the start of the day only the drive on counts, at the end
|
||||
* only the drive there. An item with no point is passed over, and the kilometres are
|
||||
* measured between the located items around it.
|
||||
*
|
||||
* Every caller seats a booking with this one rule, so the day list, the slot the desktop
|
||||
* stores for it and the road trip keep to one order.
|
||||
*/
|
||||
export function rideSeatAfter(items: readonly SeatItem[], ride: SameDayRide): number | null {
|
||||
const sorted = [...items].sort((a, b) => a.key - b.key);
|
||||
// Read the way the clock rule reads it: a ride without a clock counts as midnight.
|
||||
const departs = ride.minutes ?? 0;
|
||||
let lower = -1;
|
||||
sorted.forEach((item, i) => {
|
||||
if (item.minutes !== null && item.minutes <= departs) lower = i;
|
||||
});
|
||||
// Nothing timed binds a ride without a clock from above: wherever it goes, it takes the
|
||||
// time of the item before it and stays there.
|
||||
const next =
|
||||
ride.minutes === null
|
||||
? -1
|
||||
: sorted.findIndex((item, i) => i > lower && item.minutes !== null && item.minutes > departs);
|
||||
const upper = next < 0 ? sorted.length : next;
|
||||
// Seat `g` is behind sorted[g], -1 ahead of everything. The clock's own is behind the
|
||||
// last item timed before the ride, or with none the last seat in reach, which is where
|
||||
// the day plan's time order reads a ride it filed at the end of the day.
|
||||
if (upper - lower < 2 || !sorted.some((item) => item.point)) return null;
|
||||
const clockSeat = lower >= 0 ? lower : upper - 1;
|
||||
const pointAt = (from: number, step: 1 | -1): LatLng | null => {
|
||||
for (let i = from; i >= 0 && i < sorted.length; i += step) if (sorted[i]!.point) return sorted[i]!.point;
|
||||
return null;
|
||||
};
|
||||
const added = (g: number): number => {
|
||||
const before = pointAt(g, -1);
|
||||
const after = pointAt(g + 1, 1);
|
||||
return (
|
||||
(before ? haversineKm(before, ride.from) : 0) +
|
||||
(after ? haversineKm(ride.to, after) : 0) -
|
||||
(before && after ? haversineKm(before, after) : 0)
|
||||
);
|
||||
};
|
||||
let best = clockSeat;
|
||||
let bestKm = added(clockSeat) - haversineKm(ride.from, ride.to) * SEAT_MARGIN_SHARE;
|
||||
for (let g = lower; g < upper; g++) {
|
||||
const km = added(g);
|
||||
if (km < bestKm) {
|
||||
best = g;
|
||||
bestKm = km;
|
||||
}
|
||||
}
|
||||
if (best === clockSeat) return null;
|
||||
return best < 0 ? -Infinity : sorted[best]!.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes of a ride as the clocks tell them, across the days it spans.
|
||||
*
|
||||
@@ -309,10 +474,11 @@ function opensTheDay(role: CarrierTerminal['role']): boolean {
|
||||
* Each terminal takes the slot the day plan shows the booking in, worked out by the same
|
||||
* rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise
|
||||
* it goes behind the last stop whose time is at or before its own, otherwise at the end,
|
||||
* and the whole list is then read in clock order. Only the terminal moves, though. The
|
||||
* stops keep the order they are stored in, which is the order the road trip has always
|
||||
* driven them in, and the terminal is slotted in behind the stop that precedes it in the
|
||||
* day plan's reading.
|
||||
* and the whole list is then read in clock order. A ride that lands on the day it left
|
||||
* may sit elsewhere between the same clocks, where it adds the least road
|
||||
* (`rideSeatAfter`). Only the terminal moves, though. The stops keep the order they are
|
||||
* stored in, which is the order the road trip has always driven them in, and the
|
||||
* terminal is slotted in behind the stop that precedes it in the day plan's reading.
|
||||
*
|
||||
* A ride that leaves and lands on the same day seats its arrival right behind its
|
||||
* departure: nothing is visited in between. One that lands on a later day seats the
|
||||
@@ -355,6 +521,13 @@ export function seatCarrierStops(
|
||||
const items: Item[] = [...base];
|
||||
const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
|
||||
const firstKey = base.length ? Math.min(...base.map((b) => b.key)) : 0;
|
||||
// A terminal seated before is an item with a clock and no say in the geography: the
|
||||
// kilometres are the drive's between stops, and the terminal is not one of those yet.
|
||||
const seatItem = (item: Item): SeatItem => ({
|
||||
key: item.key,
|
||||
minutes: item.minutes,
|
||||
point: item.kind === 'stop' ? { lat: stops[item.index]!.lat, lng: stops[item.index]!.lng } : null,
|
||||
});
|
||||
ends
|
||||
.map((e) => ({ ...e, minutes: parseClock(e.end.clock) }))
|
||||
.sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
|
||||
@@ -365,11 +538,17 @@ export function seatCarrierStops(
|
||||
let after = -Infinity;
|
||||
for (const item of items)
|
||||
if (item.minutes !== null && item.minutes <= minutes) after = Math.max(after, item.key);
|
||||
// A ride that lands on the day it left goes where it adds the least road, as far
|
||||
// as the clock leaves a choice (`rideSeatAfter`), and the day plan seats it the
|
||||
// same way: behind the item that seat names, or ahead of every one.
|
||||
const ride = e.role === 'departure' ? sameDayRideOf(e.seam) : null;
|
||||
const seat = ride ? rideSeatAfter(items.map(seatItem), ride) : null;
|
||||
if (seat !== null) after = seat;
|
||||
// With no timed stop ahead of it, a departure closes the day, the way the day plan
|
||||
// lists it. An arrival that landed overnight OPENS the day it lands on: the day
|
||||
// plan happens to file it last there too, but read as a drive that puts the
|
||||
// morning's stops on the road before the traveller has landed.
|
||||
const opens = opensTheDay(e.role);
|
||||
const opens = seat === -Infinity || opensTheDay(e.role);
|
||||
key =
|
||||
after === -Infinity
|
||||
? opens
|
||||
|
||||
Reference in New Issue
Block a user