mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one it sleeps in the way Days does, over real nights and in id order, and puts each hotel at the edge of the day as a stop of its own that belongs to no assignment. The schedule, the day window, the spill into the next morning, the sums and the connected days read a night spent at one hotel as a leg going nowhere, never cut or postpone the drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its stored index. That lookup now also puts a place added behind a terminal where it fell. The rail and the phone chain draw the hotel as a row of its own that opens the booking or the stay, offers no other ways and takes no via, in every language. Until a trip switches it on, every plan is the one stored.
This commit is contained in:
@@ -1435,3 +1435,114 @@ describe('RoadtripSidebar with the drive in from the day before (#2461)', () =>
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a booked night at the edge of the day', () => {
|
||||
const bookend = (phase: 'morning' | 'evening', over: Partial<NonNullable<RoadtripStop['bookend']>> = {}, at: Partial<RoadtripStop> = {}) =>
|
||||
stop({
|
||||
assignmentId: phase === 'morning' ? -6_000_000_002 : -6_000_000_003,
|
||||
name: 'Hotel Alpenblick',
|
||||
placeId: 900,
|
||||
lat: 47.2,
|
||||
lng: 11.4,
|
||||
stopType: 'hotel',
|
||||
dwellMinutes: 0,
|
||||
bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: false, checkingIn: false, checkOut: null, ...over },
|
||||
...at,
|
||||
})
|
||||
/** Out of the hotel on its check-out morning, two places, and into the night's hotel. */
|
||||
const loop = (over: Partial<RoadtripDay> = {}) => {
|
||||
const stops = [
|
||||
bookend('morning', { checkingOut: true, checkOut: '10:00' }, { ownerIndex: 0 }),
|
||||
stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 }),
|
||||
stop({ assignmentId: 2, name: 'Falls', ownerIndex: 1 }),
|
||||
bookend('evening', {}, { ownerIndex: 2 }),
|
||||
]
|
||||
return day({
|
||||
stops,
|
||||
legs: [leg(), leg(), leg()],
|
||||
schedule: {
|
||||
entries: ['08:40', '09:40', '10:40', '11:40'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
|
||||
warnings: [],
|
||||
},
|
||||
...over,
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-064: names the hotel it sets out from and the one it ends at, and counts neither', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
|
||||
expect(screen.getByText('Check-out · Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('until 10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Back to Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
// The places are one and two; the hotel wears no number.
|
||||
expect(screen.getByText('Lookout').closest('button')).toHaveTextContent('1')
|
||||
expect(screen.getByText('11:40')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-065: the hotel rows are neither dragged nor dropped on, and carry no stay, kind or fill control', () => {
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={vi.fn()} onEditStay={vi.fn()} onSetStopKind={vi.fn()} onSetStopFill={vi.fn()} />,
|
||||
)
|
||||
expect(container.querySelectorAll('li[draggable="true"]')).toHaveLength(2)
|
||||
const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')!
|
||||
expect(hotelRow).not.toHaveAttribute('draggable')
|
||||
// The row is one button, with nothing inside it to press.
|
||||
expect(within(hotelRow).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(hotelRow).queryByText('Stay')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-066: offers other ways on the drive between the places only', () => {
|
||||
const onAskAlternatives = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onAskAlternatives={onAskAlternatives} />)
|
||||
const offered = screen.getAllByLabelText('Other ways')
|
||||
expect(offered).toHaveLength(1)
|
||||
fireEvent.click(offered[0])
|
||||
expect(onAskAlternatives).toHaveBeenCalledWith(1, { kind: 'leg', index: 1 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-067: opens the booking behind the night for an editor, the hotel for anybody else', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const onSelectStop = vi.fn()
|
||||
const editor = wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} canEditBookings />)
|
||||
fireEvent.click(screen.getByText('Back to Hotel Alpenblick'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(41)
|
||||
expect(onSelectStop).not.toHaveBeenCalled()
|
||||
editor.unmount()
|
||||
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onOpenBooking={onOpenBooking} onSelectStop={onSelectStop} />)
|
||||
fireEvent.click(screen.getByText('Check-out · Hotel Alpenblick'))
|
||||
// The place alone, without an assignment: the hotel is no stop of the day.
|
||||
expect(onSelectStop).toHaveBeenCalledWith(900)
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-068: a place is not moved past the hotel at either end of its day', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={onReorderStop} />)
|
||||
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
fireEvent.keyDown(screen.getByText('Falls').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(screen.getByText('Lookout').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 1, 1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-069: the morning marker at the hotel is drawn as the hotel row, not as a line of its own', () => {
|
||||
const resume = stop({
|
||||
assignmentId: -2_000_000_003,
|
||||
name: 'Continue journey',
|
||||
lat: 47.2,
|
||||
lng: 11.4,
|
||||
automaticNight: { phase: 'start', fromDayNumber: 1 },
|
||||
})
|
||||
const stops = [resume, bookend('morning', {}, { ownerIndex: 0 }), stop({ assignmentId: 1, name: 'Lookout', ownerIndex: 0 })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg({ distance: 0 }), leg()] })] })} />)
|
||||
expect(screen.getByText('From Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Continue journey')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-070: a drive to the hotel over the limit is flagged on the hotel row', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [loop({ driveWarnings: [{ index: 3, code: 'leg', overMinutes: 30 }] })] })} />)
|
||||
const hotelRow = screen.getByText('Back to Hotel Alpenblick').closest('li')!
|
||||
expect(within(hotelRow).getByText('+30 min')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@ import { formatDistance } from '../../utils/units'
|
||||
import { formatDate, formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { arrivingReroutable, destinationCount, isHop, legReroutable } from './roadtripRowModel'
|
||||
import { arrivingReroutable, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel'
|
||||
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle } from './nightBookend'
|
||||
import { spurWorthLabelling } from './accessSpur'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import StopFillPicker from './StopFillPicker'
|
||||
@@ -44,7 +45,11 @@ interface RoadtripSidebarProps {
|
||||
/** Legs and totals for the whole trip, computed once in the planner hook. */
|
||||
routes: RoadtripRoutes
|
||||
selectedAssignmentId?: number | null
|
||||
onSelectStop?: (placeId: number, assignmentId: number) => void
|
||||
/**
|
||||
* Selects a stop, which opens its place in the inspector. Without an assignment it is
|
||||
* the place alone: a booked night at a day's edge is the stay's place, no stop.
|
||||
*/
|
||||
onSelectStop?: (placeId: number, assignmentId?: number) => void
|
||||
/**
|
||||
* Moves a stop within its day. Absent means the chain is read-only, which is also how
|
||||
* a viewer sees it — no handles, no drop targets.
|
||||
@@ -807,13 +812,33 @@ function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const Icon = carrierIcon(stop.carrier!.type)
|
||||
return (
|
||||
<DiscRow Icon={carrierIcon(stop.carrier!.type)} starts={starts} continues={continues} onOpen={onOpen} label={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<RideEnd stop={stop} entry={entry} late={late} />
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the rail that is one button on a plain disc: a terminal, a ride, a booked night.
|
||||
* The line runs in from above unless the chain starts here, and on below while it goes on.
|
||||
*/
|
||||
function DiscRow({ Icon, starts, continues, onOpen, label, children }: {
|
||||
Icon: LucideIcon
|
||||
starts?: boolean
|
||||
continues: boolean
|
||||
onOpen?: () => void
|
||||
label?: string
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
|
||||
aria-label={label}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
@@ -824,13 +849,55 @@ function TerminalStop({ stop, entry, late, continues, starts, onOpen }: {
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<RideEnd stop={stop} entry={entry} late={late} />
|
||||
</span>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked night at the edge of the day: the hotel the day sets out from, or the one it
|
||||
* ends at (`seatNightBookends`).
|
||||
*
|
||||
* Laid out like a terminal, with no number, stay, kind picker or drag handle: it is the
|
||||
* stay's place and no stop of the day, so nothing about it is changed here. Under its line
|
||||
* the latest hour the room is handed back, on the morning it is, and whatever the drive
|
||||
* into it runs over; on the right the time the chain has the traveller there. The whole
|
||||
* row opens the booking behind the night, or the hotel's place when there is none to open.
|
||||
*/
|
||||
function BookendStop({ reading, entry, late, driveFindings, continues, starts, onOpen }: {
|
||||
reading: BookendReading
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
driveFindings: ScheduleWarning[]
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const meta = bookendMeta(reading, t, is12h)
|
||||
const badges = late.length + driveFindings.length > 0
|
||||
return (
|
||||
<DiscRow Icon={BOOKEND_ICON} starts={starts} continues={continues} onOpen={onOpen}>
|
||||
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="min-w-0 break-words font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{bookendTitle(reading, t)}
|
||||
</span>
|
||||
{meta || badges ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{meta ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{meta}</span> : null}
|
||||
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One end of a ride: the terminal's name and code, the timetable's clock under it, and
|
||||
* on the right the time the chain has the traveller there. Inside the ride's block for
|
||||
@@ -884,21 +951,7 @@ function RideBlock({ departure, arrival, entries, late, seg, continues, starts,
|
||||
const carrier = departure.carrier!
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={!onOpen}
|
||||
aria-label={onOpen ? t('roadtrip.ride.open') : undefined}
|
||||
className="group grid w-full rounded-lg text-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent disabled:cursor-default"
|
||||
style={RAIL_GRID}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
{starts ? null : <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden />}
|
||||
<span className={`${DISC} my-1 bg-surface-tertiary text-content-secondary`}>
|
||||
<Icon size={13} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" aria-hidden /> : null}
|
||||
</span>
|
||||
<DiscRow Icon={Icon} starts={starts} continues={continues} onOpen={onOpen} label={onOpen ? t('roadtrip.ride.open') : undefined}>
|
||||
<span className="my-0.5 flex min-w-0 flex-col gap-1 rounded-lg border border-edge-faint bg-surface-tertiary px-2 pb-1.5 pt-1 transition-colors group-hover:bg-surface-selected">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-content-muted">
|
||||
<Icon size={11} strokeWidth={1.8} className="shrink-0" aria-hidden />
|
||||
@@ -909,7 +962,7 @@ function RideBlock({ departure, arrival, entries, late, seg, continues, starts,
|
||||
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
|
||||
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
|
||||
</span>
|
||||
</button>
|
||||
</DiscRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1516,7 +1569,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
collapsed?: boolean
|
||||
onToggle?: () => void
|
||||
selectedAssignmentId?: number | null
|
||||
onSelectStop?: (placeId: number, assignmentId: number) => void
|
||||
onSelectStop?: RoadtripSidebarProps['onSelectStop']
|
||||
onReorderStop?: (dayId: number, assignmentId: number, toIndex: number) => void
|
||||
onMoveStopToDay?: RoadtripSidebarProps['onMoveStopToDay']
|
||||
/** What is being dragged right now, shared across days so a stop can leave its own. */
|
||||
@@ -1594,6 +1647,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
))
|
||||
: null
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
|
||||
// The morning marker at the hotel the day sets out from is drawn as that hotel's row.
|
||||
if (resumeFoldsIntoBookend(day, i)) return null
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
@@ -1608,6 +1663,33 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
|
||||
// can be late for the time it is reached and the time it is left at both at once.
|
||||
const lateness = latenessAt(i)
|
||||
// A booked night at the day's edge: not draggable, not numbered, no stay and no chips,
|
||||
// and the drive into or out of it offers no other ways (`legReroutable`).
|
||||
if (stop.bookend) {
|
||||
const reading = bookendReading(day, i)!
|
||||
const booking = bookendBooking(reading, !!canEditBookings)
|
||||
let open: (() => void) | undefined
|
||||
if (booking !== null && onOpenBooking) open = () => onOpenBooking(booking)
|
||||
else if (onSelectStop) open = () => onSelectStop(stop.placeId)
|
||||
return (
|
||||
<li key={stop.assignmentId}>
|
||||
<BookendStop
|
||||
reading={reading}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={lateness}
|
||||
driveFindings={findingsFor(i)}
|
||||
continues={i < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? <DriveBand leg={day.legs[i]} /> : null}
|
||||
{refuelBandsFor(i)}
|
||||
{i < last ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
// A terminal or a hire car's desk: not draggable, not numbered, not a place. A ride
|
||||
// that lands on the day it left is one block from its departure to its arrival, and
|
||||
// the arrival index draws nothing of its own. A lone terminal (a ride landing
|
||||
@@ -1721,7 +1803,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca
|
||||
starts={i === 0}
|
||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||
onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined}
|
||||
canMove={{ up: i > 0, down: i < last }}
|
||||
canMove={movableWithin(day, i)}
|
||||
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
|
||||
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './u
|
||||
import type { OfferedRoute } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTPIN-001..012: proving a choice with the rail's own router before it is saved.
|
||||
* FE-ALTPIN-001..013: proving a choice with the rail's own router before it is saved.
|
||||
*
|
||||
* A choice used to be one via at the point where the offer strayed furthest, written
|
||||
* without asking whether the router then drove the offer. On a ferry it did not (OSRM
|
||||
@@ -148,6 +148,19 @@ describe('railLegAt', () => {
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toBeNull()
|
||||
expect(railLegAt(days, { dayId: 3, afterIndex: 0 })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ALTPIN-013: neither is the hotel a day sets out from, so a choice on the first leg is still that leg', () => {
|
||||
// The morning's hotel borrows the first stop's index. Answering for it, the leg read
|
||||
// back was hotel to first stop, and every choice on leg 0 came back as changed.
|
||||
const bookend = (id: number, ownerIndex: number, phase: 'morning' | 'evening') => stop(id, 1, ownerIndex, {
|
||||
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
})
|
||||
const days = [day(1, [bookend(-6_000_000_002, 0, 'morning'), stop(10, 1, 0), stop(11, 1, 1), bookend(-6_000_000_003, 2, 'evening')])]
|
||||
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 0 })).toMatchObject({ from: { assignmentId: 10 }, to: { assignmentId: 11 } })
|
||||
// Behind the last stop the rail drives to tonight's hotel, and that is what it reports.
|
||||
expect(railLegAt(days, { dayId: 1, afterIndex: 1 })).toMatchObject({ from: { assignmentId: 11 }, to: { assignmentId: -6_000_000_003 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('railDriveOn', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
|
||||
import type { RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
|
||||
|
||||
@@ -111,12 +111,12 @@ export function refusalHint(
|
||||
* inside one card or across to the next. The caller hands in the days with a single stop
|
||||
* too: they draw no card, but the drive into the day after one leaves from its stop. The
|
||||
* markers an automatic night puts on the chain stand on or between stops without being
|
||||
* one, and a terminal borrows the index of a stored stop to be seated by, so neither can
|
||||
* answer for the anchor.
|
||||
* one, and a terminal or a booked night at a day's edge borrows the index of a stored stop
|
||||
* to be seated by, so none of them can answer for the anchor.
|
||||
*/
|
||||
export function railLegAt(days: readonly Pick<RoadtripDay, 'stops'>[], anchor: ViaAnchor): { from: RoadtripStop; to: RoadtripStop } | null {
|
||||
const chain = days.flatMap(day => day.stops).filter(stop => !stop.automaticNight)
|
||||
const at = chain.findIndex(stop => !stop.carrier && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
const at = chain.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
const to = at >= 0 ? chain[at + 1] : undefined
|
||||
return to ? { from: chain[at], to } : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BedDouble } from 'lucide-react'
|
||||
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle } from './nightBookend'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
|
||||
// FE-BOOKEND-001 to FE-BOOKEND-005
|
||||
|
||||
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
|
||||
phase: 'morning',
|
||||
variant: 'from',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: null,
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** A translator that shows the key and what went into it, so a test reads which sentence was asked for. */
|
||||
const t = (key: string, params?: Record<string, string | number | null>) =>
|
||||
params ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})` : key
|
||||
|
||||
describe('a booked night as the rail and the phone say it', () => {
|
||||
it('FE-BOOKEND-001: each of the four readings is its own sentence, with the stay named in it', () => {
|
||||
expect(bookendTitle(reading({ variant: 'checkOut' }), t)).toBe('roadtrip.bookend.checkOut(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ variant: 'from' }), t)).toBe('roadtrip.bookend.from(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ phase: 'evening', variant: 'back' }), t)).toBe('roadtrip.bookend.back(name=Hotel Alpenblick)')
|
||||
expect(bookendTitle(reading({ phase: 'evening', variant: 'checkIn' }), t)).toBe('roadtrip.bookend.checkIn(name=Hotel Alpenblick)')
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-002: the check-out hour is a label in the reader\'s clock', () => {
|
||||
expect(bookendMeta(reading({ variant: 'checkOut', until: '10:00' }), t, false)).toBe('roadtrip.stay.until(time=10:00)')
|
||||
expect(bookendMeta(reading({ variant: 'checkOut', until: '14:30' }), t, true)).toBe('roadtrip.stay.until(time=2:30 PM)')
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-003: nothing under the line when the morning hands no room back', () => {
|
||||
expect(bookendMeta(reading(), t, false)).toBeNull()
|
||||
expect(bookendMeta(reading({ phase: 'evening', variant: 'back' }), t, false)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-004: a tap opens the booking behind the night for somebody who may edit bookings', () => {
|
||||
expect(bookendBooking(reading(), true)).toBe(41)
|
||||
// The editor is the only view a hotel booking has, so anybody else gets the place.
|
||||
expect(bookendBooking(reading(), false)).toBeNull()
|
||||
// A stay entered without a booking has none to open.
|
||||
expect(bookendBooking(reading({ reservationId: null }), true)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BOOKEND-005: the hotel wears the bed the stay chips wear', () => {
|
||||
expect(BOOKEND_ICON).toBe(BedDouble)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BedDouble, type LucideIcon } from 'lucide-react'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { BookendReading } from './roadtripRowModel'
|
||||
|
||||
/**
|
||||
* How a booked night at the edge of a day reads on the rail and in the phone chain, and
|
||||
* what a tap on it opens.
|
||||
*
|
||||
* One module for both shells, the way `carrierRide` is for a ride: the two would otherwise
|
||||
* carry the same sentence and the same decision each, and the duplication budget does not
|
||||
* stretch to that. What the row IS comes from the row model (`bookendReading`); this only
|
||||
* says it.
|
||||
*/
|
||||
|
||||
/** The bed the day plan's stay chips wear, so the hotel looks like itself here too. */
|
||||
export const BOOKEND_ICON: LucideIcon = BedDouble
|
||||
|
||||
/** "Check-out · the stay", "From …", "Back to …" or "Check-in · …". */
|
||||
export function bookendTitle(reading: BookendReading, t: TranslationFn): string {
|
||||
return t(`roadtrip.bookend.${reading.variant}`, { name: reading.name })
|
||||
}
|
||||
|
||||
/**
|
||||
* The line under it: on a check-out morning the latest the room is handed back, in the
|
||||
* reader's own clock format. A label, never the time the drive leaves at (#2357).
|
||||
*/
|
||||
export function bookendMeta(reading: BookendReading, t: TranslationFn, is12h: boolean): string | null {
|
||||
return reading.until ? t('roadtrip.stay.until', { time: formatClockTime(reading.until, is12h) }) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking a tap on the row opens, or null when it opens the stay's place instead.
|
||||
*
|
||||
* The booking behind the night when there is one and this reader may open it: a hotel
|
||||
* booking has only its editor, the split `bookingOpens` makes for a chip. Otherwise the
|
||||
* place, which anybody may look at.
|
||||
*/
|
||||
export function bookendBooking(reading: BookendReading, canEditBookings: boolean): number | null {
|
||||
return canEditBookings ? reading.reservationId : null
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arrivingReroutable,
|
||||
bookendReading,
|
||||
destinationCount,
|
||||
firstStopOfPlace,
|
||||
legReroutable,
|
||||
movableWithin,
|
||||
pickWarning,
|
||||
resumeFoldsIntoBookend,
|
||||
roadtripRows,
|
||||
stageClocks,
|
||||
stageEnd,
|
||||
@@ -14,9 +17,9 @@ import {
|
||||
type StopRow,
|
||||
} from './roadtripRowModel'
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { BookendPhase, NightBookend, RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTROW-001 to FE-RTROW-058
|
||||
// FE-RTROW-001 to FE-RTROW-066
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -700,3 +703,129 @@ describe('the drive in from the day before (#2461)', () => {
|
||||
expect(arrivingReroutable(joined({ stops: [night('start'), stop('Potsdam')] }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a booked night at the edge of the day', () => {
|
||||
const reading = (phase: BookendPhase, over: Partial<NightBookend> = {}): NightBookend => ({
|
||||
phase,
|
||||
accommodationId: 5,
|
||||
reservationId: 41,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
...over,
|
||||
})
|
||||
const hotel = (phase: BookendPhase, over: Partial<NightBookend> = {}, at: Partial<RoadtripStop> = {}) =>
|
||||
stop('Hotel Alpenblick', {
|
||||
assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015,
|
||||
placeId: 900,
|
||||
lat: 45,
|
||||
lng: 7,
|
||||
stopType: 'hotel',
|
||||
dwellMinutes: 0,
|
||||
bookend: reading(phase, over),
|
||||
...at,
|
||||
})
|
||||
/** From the hotel, two places, back to the hotel, each with a clock. */
|
||||
const loop = (over: Partial<RoadtripDay> = {}) => {
|
||||
const stops = [
|
||||
hotel('morning'),
|
||||
stop('Lookout', { placeId: 1 }),
|
||||
stop('Falls', { placeId: 2, ownerIndex: 1 }),
|
||||
hotel('evening', {}, { ownerIndex: 2 }),
|
||||
]
|
||||
return day(stops, {
|
||||
schedule: { entries: [entry('08:40'), entry('09:30'), entry('11:00'), entry('12:10')], warnings: [] },
|
||||
...over,
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-RTROW-059: reads as a check-out, a morning, an evening back or a check-in', () => {
|
||||
const out = day([hotel('morning', { checkingOut: true, checkOut: '10:00' }), stop('Zoo')])
|
||||
expect(bookendReading(out, 0)).toEqual({
|
||||
phase: 'morning',
|
||||
variant: 'checkOut',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: '10:00',
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
})
|
||||
expect(bookendReading(loop(), 0)).toMatchObject({ variant: 'from', until: null })
|
||||
expect(bookendReading(loop(), 3)).toMatchObject({ variant: 'back', phase: 'evening' })
|
||||
// The check-in day: back when the stay's own stop heads the card, a check-in without it.
|
||||
const stayed = day([stop('Hotel Alpenblick', { lat: 45, lng: 7, night: true }), stop('Lookout'), hotel('evening', { checkingIn: true })])
|
||||
expect(bookendReading(stayed, 2)?.variant).toBe('back')
|
||||
const transfer = day([hotel('morning', { checkingOut: true }), hotel('evening', { checkingIn: true }, { name: 'Wallinga', lat: 46 })])
|
||||
expect(bookendReading(transfer, 1)).toMatchObject({ variant: 'checkIn', name: 'Wallinga' })
|
||||
// Nothing to read on an ordinary stop, or past the end.
|
||||
expect(bookendReading(loop(), 1)).toBeNull()
|
||||
expect(bookendReading(loop(), 9)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RTROW-060: a row of its own, unnumbered, that counts as no destination', () => {
|
||||
const rows = roadtripRows(loop())
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'stop'])
|
||||
const stops = stopRows(rows)
|
||||
expect(stops.map(r => r.number)).toEqual([null, 1, 2, null])
|
||||
expect(stops.map(r => r.bookend?.variant ?? null)).toEqual(['from', null, null, 'back'])
|
||||
expect(stops[0]!.service).toBe(true)
|
||||
expect(destinationCount(loop())).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-RTROW-061: offers no other ways from or to the hotel, on a leg or on the drive in', () => {
|
||||
expect([0, 1, 2].map(i => legReroutable(loop(), i))).toEqual([false, true, false])
|
||||
const joined = (from: RoadtripStop, first: RoadtripStop) => day([first, stop('Falls')], {
|
||||
arrivingLeg: { ...seg(3), distance: 120_000, duration: 5_400 },
|
||||
arrivingFrom: from,
|
||||
arrivingLine: [[45, 7], [48, 11]],
|
||||
})
|
||||
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), stop('Lookout')))).toBe(true)
|
||||
expect(arrivingReroutable(joined(hotel('evening'), stop('Lookout')))).toBe(false)
|
||||
expect(arrivingReroutable(joined(stop('Town', { ownerDayId: 6 }), hotel('morning')))).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-RTROW-062: the morning marker at the hotel the day sets out from is folded into its row', () => {
|
||||
const resume = night('start')
|
||||
const at = { ...resume, lat: 45, lng: 7 }
|
||||
const folded = day([at, hotel('morning'), stop('Lookout')], {
|
||||
schedule: { entries: [entry('08:00'), entry('08:00'), entry('09:00')], warnings: [] },
|
||||
})
|
||||
expect(resumeFoldsIntoBookend(folded, 0)).toBe(true)
|
||||
expect(roadtripRows(folded).map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
expect(stageClocks(roadtripRows(folded)).start).toBe('08:00')
|
||||
// A marker somewhere else keeps its own row: the drive from there to the hotel is real.
|
||||
const apart = day([resume, hotel('morning'), stop('Lookout')])
|
||||
expect(resumeFoldsIntoBookend(apart, 0)).toBe(false)
|
||||
expect(roadtripRows(apart)[0]).toMatchObject({ kind: 'auto', phase: 'resume' })
|
||||
expect(resumeFoldsIntoBookend(folded, 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-RTROW-063: the stage starts when the hotel is left and arrives when it is reached again', () => {
|
||||
const rows = roadtripRows(loop())
|
||||
expect(stageClocks(rows)).toEqual({ start: '08:40', arrive: '12:10' })
|
||||
expect(stageEnd(rows)?.bookend?.variant).toBe('back')
|
||||
})
|
||||
|
||||
it('FE-RTROW-064: a pin at the hotel opens the stay’s own stop, never a bookend', () => {
|
||||
const own = stop('Hotel Alpenblick', { placeId: 900, assignmentId: 77, ownerDayId: 3 })
|
||||
expect(firstStopOfPlace([loop(), day([own])], 900)).toBe(own)
|
||||
expect(firstStopOfPlace([loop()], 900)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RTROW-065: a stop moves past anything but the hotel at the edge of its day', () => {
|
||||
expect(movableWithin(loop(), 1)).toEqual({ up: false, down: true })
|
||||
expect(movableWithin(loop(), 2)).toEqual({ up: true, down: false })
|
||||
const plain = day([stop('A'), stop('B'), stop('C')])
|
||||
expect([0, 1, 2].map(i => movableWithin(plain, i))).toEqual([
|
||||
{ up: false, down: true },
|
||||
{ up: true, down: true },
|
||||
{ up: true, down: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-RTROW-066: up next is a place, not the hotel the day comes back to', () => {
|
||||
// 11:30 is past both places and before the hotel: the last place is still next.
|
||||
expect(upNextStop(loop(), 11 * 60 + 30, true)?.row.stop.name).toBe('Falls')
|
||||
expect(upNextStop(loop(), 8 * 60 + 45, true)?.row.stop.name).toBe('Lookout')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
import { isCarrierMode, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { isCarrierMode, isStoredStop, type BookendPhase, type CarrierTerminal, type RoadtripDay, type RoadtripStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import { readStay } from './stayReading'
|
||||
|
||||
/**
|
||||
@@ -54,6 +54,73 @@ export interface StopRow {
|
||||
/** How long the stop is stood at, which for one left at a set time is what that time leaves. */
|
||||
dwellMinutes: number | null
|
||||
offRoadMeters: number | null
|
||||
/** What the row says about the booked night it stands for, when it is one; see `bookendReading`. */
|
||||
bookend: BookendReading | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked night at the edge of a day, as a row reads it (`seatNightBookends`).
|
||||
*
|
||||
* Four ways of saying it. The morning of the day the stay is handed back is a check-out,
|
||||
* with the latest hour it may be; any other morning sets out from the stay. The evening
|
||||
* of the day the stay begins is a check-in, unless the stay's own stop is already on the
|
||||
* card before it, in which case the day comes back to it like any other evening.
|
||||
*/
|
||||
export interface BookendReading {
|
||||
phase: BookendPhase
|
||||
variant: 'checkOut' | 'from' | 'back' | 'checkIn'
|
||||
name: string
|
||||
/** The latest the room is handed back, on the check-out morning. A label, never a departure. */
|
||||
until: string | null
|
||||
/** The booking a tap opens, or null for a stay entered without one. */
|
||||
reservationId: number | null
|
||||
accommodationId: number
|
||||
placeId: number
|
||||
}
|
||||
|
||||
export function bookendReading(day: Pick<RoadtripDay, 'stops'>, index: number): BookendReading | null {
|
||||
const stop = day.stops[index]
|
||||
const bookend = stop?.bookend
|
||||
if (!stop || !bookend) return null
|
||||
const morning = bookend.phase === 'morning'
|
||||
const stayStopBefore = day.stops
|
||||
.slice(0, index)
|
||||
.some(s => isStoredStop(s) && s.lat === stop.lat && s.lng === stop.lng)
|
||||
const evening = bookend.checkingIn && !stayStopBefore ? 'checkIn' : 'back'
|
||||
return {
|
||||
phase: bookend.phase,
|
||||
variant: morning ? (bookend.checkingOut ? 'checkOut' : 'from') : evening,
|
||||
name: stop.name,
|
||||
until: morning && bookend.checkingOut ? bookend.checkOut : null,
|
||||
reservationId: bookend.reservationId,
|
||||
accommodationId: bookend.accommodationId,
|
||||
placeId: stop.placeId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the automatic morning marker at `index` is the same point as the hotel the day
|
||||
* sets out from right after it. The hotel's row carries that morning, clock and all, and a
|
||||
* "continue journey" line above it would say the same thing twice.
|
||||
*/
|
||||
export function resumeFoldsIntoBookend(day: Pick<RoadtripDay, 'stops'>, index: number): boolean {
|
||||
const stop = day.stops[index]
|
||||
const next = day.stops[index + 1]
|
||||
return stop?.automaticNight?.phase === 'start'
|
||||
&& next?.bookend?.phase === 'morning'
|
||||
&& stop.lat === next.lat
|
||||
&& stop.lng === next.lng
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the stop at `index` may be moved by one: not past a booked night at the day's
|
||||
* edge, which is no stored stop and holds its place. Past anything else, as before.
|
||||
*/
|
||||
export function movableWithin(day: Pick<RoadtripDay, 'stops'>, index: number): { up: boolean; down: boolean } {
|
||||
return {
|
||||
up: index > 0 && !day.stops[index - 1]?.bookend,
|
||||
down: index < day.stops.length - 1 && !day.stops[index + 1]?.bookend,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,17 +153,12 @@ export function stageOf(days: readonly RoadtripDay[], dayId: number | null): Roa
|
||||
return days.find(d => d.dayId === dayId) ?? null
|
||||
}
|
||||
|
||||
function stopRow(
|
||||
stop: RoadtripStop,
|
||||
index: number,
|
||||
number: number | null,
|
||||
schedule: RoadtripDay['schedule'],
|
||||
driveWarnings: readonly ScheduleWarning[],
|
||||
): StopRow {
|
||||
function stopRow(day: RoadtripDay, index: number, number: number | null): StopRow {
|
||||
// Only ever called for a real stop: an automatic night is the shell's own marker
|
||||
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
|
||||
const entry = schedule.entries[index]
|
||||
const mine = driveWarnings.filter(w => w.index === index)
|
||||
const stop = day.stops[index]
|
||||
const entry = day.schedule.entries[index]
|
||||
const mine = day.driveWarnings.filter(w => w.index === index)
|
||||
return {
|
||||
kind: 'stop',
|
||||
stop,
|
||||
@@ -109,6 +171,7 @@ function stopRow(
|
||||
warning: pickWarning(mine),
|
||||
dwellMinutes: readStay(stop, entry).minutes,
|
||||
offRoadMeters: stop.offRoadMeters ?? null,
|
||||
bookend: bookendReading(day, index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +259,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
|
||||
|
||||
const automatic = !!stop.automaticNight
|
||||
// The morning marker at the hotel the day sets out from is that hotel's row.
|
||||
if (resumeFoldsIntoBookend(day, i)) return
|
||||
if (automatic) {
|
||||
rows.push({
|
||||
kind: 'auto',
|
||||
@@ -208,8 +273,8 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
index: i,
|
||||
carrier: stop.carrier,
|
||||
seg: day.legs[i],
|
||||
departure: stopRow(stop, i, null, day.schedule, day.driveWarnings),
|
||||
arrival: stopRow(next!, i + 1, null, day.schedule, day.driveWarnings),
|
||||
departure: stopRow(day, i, null),
|
||||
arrival: stopRow(day, i + 1, null),
|
||||
})
|
||||
// The road out of the arrival belongs to this row too; the pass below only
|
||||
// looks at the index it is on.
|
||||
@@ -220,7 +285,7 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
// place the trip is for: it carries no number, the way a service stop carries none.
|
||||
const unnumbered = isServiceStopType(stop.stopType) || !!stop.carrier
|
||||
if (!unnumbered) number += 1
|
||||
rows.push(stopRow(stop, i, unnumbered ? null : number, day.schedule, day.driveWarnings))
|
||||
rows.push(stopRow(day, i, unnumbered ? null : number))
|
||||
}
|
||||
|
||||
pushLeg(i)
|
||||
@@ -268,6 +333,11 @@ export function legReroutable(day: RoadtripDay, index: number): boolean {
|
||||
// index of the stop after it. The road INTO a departure terminal leaves a stored
|
||||
// stop and can be offered other ways like any other.
|
||||
&& !day.stops[index].carrier
|
||||
// The drive from the hotel a day sets out from, or to the one it ends at, takes no
|
||||
// via: the morning's has no index to file one at, and the evening's index is the
|
||||
// road into tomorrow's, which a choice here would bend instead.
|
||||
&& !day.stops[index].bookend
|
||||
&& !day.stops[index + 1].bookend
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +361,8 @@ export function arrivingReroutable(day: RoadtripDay): boolean {
|
||||
&& !from.carrier
|
||||
&& !from.automaticNight
|
||||
&& !to.automaticNight
|
||||
&& !from.bookend
|
||||
&& !to.bookend
|
||||
}
|
||||
|
||||
/** Stops that carry a number, for a count that agrees with the numbering above. */
|
||||
@@ -380,12 +452,13 @@ export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
|
||||
* traveller reading the drive from its start reaches first.
|
||||
*
|
||||
* An automatic night can sit on a place's position, but nobody chose to stop there, so it
|
||||
* never answers. Null when none of the days stops at the place, which is a pin the road
|
||||
* never answers. Neither does a booked night at a day's edge: it is the stay's place, and
|
||||
* no stop of the day, so the inspector answers for the hotel instead. Null when none of the days stops at the place, which is a pin the road
|
||||
* trip cannot explain and the caller hands to the plan's own place inspector instead.
|
||||
*/
|
||||
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
|
||||
for (const day of days) {
|
||||
const stop = day.stops.find(s => !s.automaticNight && !s.carrier && s.placeId === placeId)
|
||||
const stop = day.stops.find(s => isStoredStop(s) && s.placeId === placeId)
|
||||
if (stop) return stop
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dayColor } from './dayColors'
|
||||
import { stageMapData, stagePlaceIds, stagePoints } from './stageMap'
|
||||
import type { AccessSpur, RoadtripDay, RoadtripRoutes, RoadtripStop } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-RTSTAGE-001 to FE-RTSTAGE-015
|
||||
// FE-RTSTAGE-001 to FE-RTSTAGE-021
|
||||
|
||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||
return {
|
||||
@@ -248,3 +248,22 @@ describe('stagePoints', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stage that starts or ends at a booked night', () => {
|
||||
it('FE-RTSTAGE-021: the hotel is on the stage, pin, fit and walk from the road, when only its bookend stands there', () => {
|
||||
const hotel = stop('Hotel Alpenblick', {
|
||||
assignmentId: -6_000_000_014,
|
||||
placeId: 900,
|
||||
lat: 47.2,
|
||||
lng: 11.4,
|
||||
stopType: 'hotel',
|
||||
bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
})
|
||||
const stage = day([hotel, stop('Kassel', { placeId: 11, lat: 51.31, lng: 9.49 })])
|
||||
const walk = spur(keyFor(hotel))
|
||||
const data = stageMapData(routes({ lines: [[[47.2, 11.4], [51.31, 9.49]]], lineDays: [2], lineJoins: [false], accessLines: [walk] }), stage, false)
|
||||
expect([...data.placeIds]).toEqual([900, 11])
|
||||
expect(data.focusPoints[0]).toEqual([47.2, 11.4])
|
||||
expect(data.accessLines).toEqual([walk])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,3 +80,24 @@ describe('dayBookings (#2428)', () => {
|
||||
expect(bookingClock({ reservation_time: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayBookings and a booked night at the edge of the day', () => {
|
||||
it('FE-STOPBOOKINGS-006: a table at the hotel hangs under the hotel stop, never under the bookend in front of it', () => {
|
||||
const bookend = (phase: 'morning' | 'evening', assignmentId: number) => ({
|
||||
assignmentId,
|
||||
placeId: 900,
|
||||
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
})
|
||||
const withHotel = {
|
||||
dayId: 1,
|
||||
stops: [bookend('morning', -6_000_000_002), { assignmentId: 11, placeId: 110 }, { assignmentId: 13, placeId: 900 }],
|
||||
}
|
||||
const dinner = booking({ id: 9, title: 'Dinner at the hotel', place_id: 900 })
|
||||
expect([...dayBookings(withHotel, [dinner]).atStop.keys()]).toEqual([2])
|
||||
// With the hotel only there as the evening's bookend, the table is the day's, for no stop.
|
||||
const backOnly = { dayId: 1, stops: [{ assignmentId: 11, placeId: 110 }, bookend('evening', -6_000_000_003)] }
|
||||
const { atStop, loose } = dayBookings(backOnly, [dinner])
|
||||
expect(atStop.size).toBe(0)
|
||||
expect(loose.map(r => r.title)).toEqual(['Dinner at the hotel'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
|
||||
import { isCarrierType, isRentalType, isStoredStop } from '@trek/shared/roadtrip'
|
||||
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
|
||||
import { hidesOnMiddleDay } from '../../utils/dayMerge'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
@@ -60,7 +60,7 @@ function spansDay(r: Reservation, dayId: number, dayOrder?: (dayId: number) => n
|
||||
}
|
||||
|
||||
export function dayBookings(
|
||||
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
|
||||
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight' | 'bookend'>[] },
|
||||
reservations: readonly Reservation[],
|
||||
/** A day's position in the trip by its id, so a booking spanning days is on the days between too. */
|
||||
dayOrder?: (dayId: number) => number | null | undefined,
|
||||
@@ -71,9 +71,11 @@ export function dayBookings(
|
||||
for (const r of reservations) {
|
||||
if (drawnElsewhere(r.type)) continue
|
||||
const stops = day.stops
|
||||
// Only a real stop can hold a booking: a terminal has a negative place and an
|
||||
// automatic night has no assignment anybody pinned to.
|
||||
const real = (i: number): boolean => !stops[i]!.carrier && !stops[i]!.automaticNight
|
||||
// Only a real stop can hold a booking: a terminal has a negative place, an automatic
|
||||
// night has no assignment anybody pinned to, and a booked night at the day's edge is
|
||||
// the stay's place on a row that carries no chips. A table at the hotel belongs to the
|
||||
// hotel's own stop, or under the day.
|
||||
const real = (i: number): boolean => isStoredStop(stops[i]!)
|
||||
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
|
||||
const onDay = spansDay(r, day.dayId, dayOrder)
|
||||
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
|
||||
|
||||
@@ -30,8 +30,8 @@ function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, ownerDayId: 1, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 1, ownerDayId: 7, ownerIndex: 0, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, ownerDayId: 7, ownerIndex: 1, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
@@ -83,7 +83,7 @@ beforeEach(() => {
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('clears the original leg when an automatic pause splits its displayed days', async () => {
|
||||
const [a, b] = day().stops
|
||||
const [a, b] = day().stops.map(s => ({ ...s, ownerDayId: 1 }))
|
||||
const end = { ...a, assignmentId: -10, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } }
|
||||
const start = { ...end, assignmentId: -11, automaticNight: { phase: 'start' as const, fromDayNumber: 1 } }
|
||||
const split = routes([
|
||||
@@ -239,6 +239,45 @@ describe('useFollowTrack', () => {
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-013: the hotel a day sets out from and comes back to is no stop the track is fitted between', async () => {
|
||||
const [a, b] = day().stops
|
||||
const bookend = (phase: 'morning' | 'evening', ownerIndex: number) => ({
|
||||
...a, assignmentId: phase === 'morning' ? -6_000_000_014 : -6_000_000_015, ownerIndex, placeId: 900, lat: 51.9, lng: 13.5, stopType: 'hotel',
|
||||
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
})
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day({ stops: [bookend('morning', 0), a, b, bookend('evening', 2)] })]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
// One leg, A to B: a via fitted to the drive to or from the hotel would be filed behind
|
||||
// a stop it does not leave from.
|
||||
const [, written, legs] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(legs).toEqual([0])
|
||||
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
|
||||
await act(async () => { await result.current.clear() })
|
||||
expect(vias.addMany).toHaveBeenLastCalledWith(7, [], [0], null)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-014: nor is a terminal, and a day a night drive split over two cards is fitted whole', async () => {
|
||||
const [a, b] = day().stops
|
||||
const terminal = (role: 'departure' | 'arrival') => ({
|
||||
...a, assignmentId: role === 'departure' ? -3_000_000_140 : -3_000_000_141, ownerIndex: 1, placeId: -70,
|
||||
carrier: { reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null },
|
||||
})
|
||||
const next = { ...b, assignmentId: 3, ownerDayId: 8, ownerIndex: 0, placeId: 30, name: 'C' }
|
||||
const cards = routes([
|
||||
day({ stops: [a, terminal('departure'), terminal('arrival')] }),
|
||||
day({ dayId: 8, dayNumber: 2, stops: [b, next] }),
|
||||
])
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], cards, vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.clear() })
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
|
||||
import type { LatLng } from './corridor'
|
||||
import { isStoredStop } from '@trek/shared/roadtrip'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
|
||||
@@ -99,13 +100,20 @@ export function useFollowTrack(
|
||||
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => {
|
||||
const original = day?.automaticSchedule
|
||||
? routes.days.flatMap(d => d.stops).filter(s => !s.automaticNight && s.ownerDayId === dayId).sort((a, b) => a.ownerIndex - b.ownerIndex)
|
||||
: day?.stops ?? []
|
||||
return original.map(s => ({ lat: s.lat, lng: s.lng }))
|
||||
}, [day, routes.days, dayId])
|
||||
/**
|
||||
* The day's stored stops, in their stored order, wherever the cards draw them.
|
||||
*
|
||||
* A via is filed behind a stored stop, by its index (`legs` in the plan is that index),
|
||||
* so the track is fitted between those and nothing else: not the markers a day window
|
||||
* adds, not a terminal and not the hotel a day starts or ends at, each of which would
|
||||
* shift every via behind it one leg on. Read across every card because a night drive
|
||||
* draws some of them on the next one.
|
||||
*/
|
||||
const stops = useMemo<LatLng[]>(() => routes.days
|
||||
.flatMap(d => d.stops)
|
||||
.filter(s => isStoredStop(s) && s.ownerDayId === dayId)
|
||||
.sort((a, b) => a.ownerIndex - b.ownerIndex)
|
||||
.map(s => ({ lat: s.lat, lng: s.lng })), [routes.days, dayId])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasLeaving, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { assembleRoadtrip, carrierLegsFor, carrierSeam, foldRouteRun, isCarrierMode, isStationaryJoin, mergeRouteSegments, seatCarrierStops, standsAsDay, terminalAssignmentId, viasOnLeg, type CarrierSeam, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { avoidedClasses, calculateRouteWithLegs, routeEngineFor, RoutingRefusedError, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
@@ -56,7 +56,7 @@ const stopKey = (s: RoadtripStop): string =>
|
||||
/**
|
||||
* Everything a seam's answer depends on besides its two stops, as one comparable string:
|
||||
* the mode it is driven in, the classes the trip avoids, and the via points that shape
|
||||
* the drive leaving `from`.
|
||||
* the drive from `from` to `to` (`viasOnLeg`).
|
||||
*
|
||||
* Read in two places that must agree: when deciding whether a seam still matches the
|
||||
* answer already in hand, and when recording what an answer was fetched for. One function
|
||||
@@ -65,14 +65,14 @@ const stopKey = (s: RoadtripStop): string =>
|
||||
* seam round and found every seam already answered, so the drive between two days kept
|
||||
* its old road and its old minutes until the page was loaded again.
|
||||
*/
|
||||
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>, mode: string, avoidKey: string, unit: string): string =>
|
||||
const seamShape = (from: RoadtripStop, to: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>, mode: string, avoidKey: string, unit: string): string =>
|
||||
[
|
||||
mode,
|
||||
avoidKey,
|
||||
// The seam's printed texts are in this unit, so a switch asks for it again, the way a
|
||||
// day's own run is asked for again.
|
||||
unit,
|
||||
viasLeaving(from, viasByDay[from.ownerDayId] ?? [])
|
||||
viasOnLeg(from, to, viasByDay[from.ownerDayId] ?? [])
|
||||
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
||||
.join('|'),
|
||||
].join('#')
|
||||
@@ -459,8 +459,8 @@ export function useRoadtripRoutes(
|
||||
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
|
||||
// index — this used to be the position within the chain, so on any day that
|
||||
// received a night drive every via matched nothing and quietly stopped
|
||||
// shaping the road.
|
||||
viasLeaving(stop, viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||
// shaping the road. None bend the drive from or to a booked night's hotel.
|
||||
viasOnLeg(stop, run[i + 1], viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
||||
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
||||
})
|
||||
|
||||
@@ -610,6 +610,8 @@ export function useRoadtripRoutes(
|
||||
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
|
||||
// The two ends of one ride: the leg between them is the booking's, never a road.
|
||||
if (rideLegs[legKey(from, to)]) return
|
||||
// A night spent at one hotel: nothing is driven, so nothing is asked.
|
||||
if (isStationaryJoin(from, to)) return
|
||||
// Routed as part of a day's own run — that request is rebuilt whenever its vias
|
||||
// change, so there is nothing to catch up here.
|
||||
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
|
||||
@@ -618,7 +620,7 @@ export function useRoadtripRoutes(
|
||||
// nothing at all: the first answer was cached under the pair, and dragging the
|
||||
// point changed the request nobody was going to send again.
|
||||
const mode = legModeOf(from, to, dayId)
|
||||
const shape = seamShape(from, viasByDay, mode, avoidKey, distanceUnit)
|
||||
const shape = seamShape(from, to, viasByDay, mode, avoidKey, distanceUnit)
|
||||
const have = seamLegs[legKey(from, to)]
|
||||
const current = have?.shape === shape
|
||||
// What the drawn join could not avoid is flagged on the card it arrives on, the way
|
||||
@@ -710,7 +712,7 @@ export function useRoadtripRoutes(
|
||||
// theirs. Without them a seam is the one stretch of the trip a via cannot
|
||||
// shape: it is asked for on its own, so the points the traveller dropped on it
|
||||
// never reached the router and dragging one did visibly nothing.
|
||||
const shaping = viasLeaving(seam.from, viasByDay[seam.from.ownerDayId] ?? [])
|
||||
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
|
||||
const r = await calculateRouteWithLegs(
|
||||
[
|
||||
{ lat: seam.from.lat, lng: seam.from.lng },
|
||||
|
||||
@@ -4,18 +4,19 @@ import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
|
||||
import { BOOKEND_ICON, bookendMeta, bookendTitle } from '../../../../components/Roadtrip/nightBookend'
|
||||
import { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
import { formatClockTime } from '../../../../utils/formatters'
|
||||
import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { BookendReading, RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
|
||||
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
|
||||
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The four row types of the mobile drive chain, plus the two bands that interrupt it.
|
||||
* The row types of the mobile drive chain, plus the two bands that interrupt it.
|
||||
*
|
||||
* What the desktop rail does in five stacked 8px badges per stop, this does in at most
|
||||
* two 10.5px marks: on touch there is no hover, so a badge nobody can explain is worse
|
||||
@@ -127,10 +128,25 @@ function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop of the stage. The whole row is the tap target, at least 46px tall, which is
|
||||
* why it is a div with role rather than a button: the desktop needs controls nested
|
||||
* inside its row, and a button inside a button is not markup.
|
||||
* A row of the stage that is its own tap target, at least 46px tall, which is why it is a
|
||||
* div with role rather than a button: the desktop needs controls nested inside its row,
|
||||
* and a button inside a button is not markup.
|
||||
*/
|
||||
function TapRow({ onOpen, children }: { onOpen: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }}
|
||||
className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A stop of the stage; see `TapRow`. */
|
||||
export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
row: StopRow
|
||||
chrome: RowChrome
|
||||
@@ -157,13 +173,7 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }}
|
||||
className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }}
|
||||
>
|
||||
<TapRow onOpen={onOpen}>
|
||||
<span className="flex justify-center"><Disc row={row} t={t} onPickKind={onPickKind} /></span>
|
||||
<span className="min-w-0 py-2">
|
||||
<span className={`block text-[0.875rem] leading-[1.25] ${row.service ? 'truncate font-medium text-m-muted' : 'line-clamp-2 font-semibold text-m-ink'}`}>
|
||||
@@ -178,7 +188,47 @@ export function RtStopRow({ row, chrome, onOpen, onPickKind }: {
|
||||
{formatClockTime(row.time, chrome.is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TapRow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked night at the edge of the stage: the hotel the day sets out from, or the one it
|
||||
* ends at. Laid out like a stop, on the bed a stay wears, with no number and no kind to
|
||||
* pick: it is the stay's place, not a stop of the day. Under it the latest hour the room
|
||||
* is handed back, on the morning it is, and the one finding a row has room for. The whole
|
||||
* row opens the booking behind the night, or the stay.
|
||||
*/
|
||||
export function RtBookendRow({ row, bookend, chrome, onOpen }: {
|
||||
row: StopRow
|
||||
bookend: BookendReading
|
||||
chrome: RowChrome
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const { t } = chrome
|
||||
const meta = bookendMeta(bookend, t, chrome.is12h)
|
||||
return (
|
||||
<TapRow onOpen={onOpen}>
|
||||
<span className="flex justify-center">
|
||||
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<BOOKEND_ICON size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 py-2">
|
||||
<span className="line-clamp-2 block text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{bookendTitle(bookend, t)}</span>
|
||||
{(meta || row.warning) && (
|
||||
<span className="mt-[4px] flex flex-wrap items-center gap-[5px]">
|
||||
{meta && <span className="font-geist text-[0.65625rem] font-medium text-m-muted">{meta}</span>}
|
||||
{row.warning && warningMark(row.warning, chrome)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{row.time && (
|
||||
<span dir="ltr" className="whitespace-nowrap text-[0.8125rem] font-medium tabular-nums text-m-faint">
|
||||
{formatClockTime(row.time, chrome.is12h)}
|
||||
</span>
|
||||
)}
|
||||
</TapRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||
@@ -19,6 +19,7 @@ import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { arrivingReroutable, legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { ARRIVING_DRIVE, type RailDrive } from '../../../../components/Roadtrip/useRouteAlternatives'
|
||||
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
|
||||
import { bookendBooking } from '../../../../components/Roadtrip/nightBookend'
|
||||
import { getDayOrder } from '../../../../utils/dayOrder'
|
||||
import { undatedRides } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
@@ -84,6 +85,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
const openStop = (row: StopRow) => {
|
||||
// A terminal is the booking's, and opens it: there is no stop sheet for an airport.
|
||||
if (row.stop.carrier) shell.openSheet('transport', { reservationId: row.stop.carrier.reservationId })
|
||||
else if (row.bookend) openBookend(row.stop.ownerDayId, row.bookend)
|
||||
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
|
||||
}
|
||||
|
||||
@@ -116,6 +118,16 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
planner.setEditingReservation(res)
|
||||
planner.setShowReservationModal(true)
|
||||
}
|
||||
// A booked night at the edge of the stage is no stop, and has no stop sheet: it opens the
|
||||
// booking behind the night, else the stay for somebody who may edit days, else the
|
||||
// hotel's place, the way a stay chip in the day timeline does (#2210).
|
||||
const openBookend = (dayId: number, reading: NonNullable<StopRow['bookend']>) => {
|
||||
const booking = bookendBooking(reading, canEditBookings)
|
||||
const res = booking === null ? undefined : planner.reservations.find(r => r.id === booking)
|
||||
if (res) openBooking(res)
|
||||
else if (planner.can('day_edit', planner.trip)) shell.openSheet('accommodation', { dayId, accId: reading.accommodationId })
|
||||
else planner.handlePlaceClick(reading.placeId)
|
||||
}
|
||||
|
||||
// The search bar sits in the same band on both halves, at the same offset, so the
|
||||
// list/map switch never moves it: it is one stage seen two ways, and a control that
|
||||
@@ -261,6 +273,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
|
||||
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
|
||||
{rt.rows.map((row, i) => {
|
||||
if (row.kind === 'stop' && row.bookend) {
|
||||
return <RtBookendRow key={`s${i}`} row={row} bookend={row.bookend} chrome={chrome} onOpen={() => openStop(row)} />
|
||||
}
|
||||
if (row.kind === 'stop') {
|
||||
const chips = bookings.atStop.get(row.stop.assignmentId)
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { isStoredStop } from '@trek/shared/roadtrip'
|
||||
import { locateStop, missedLeaveOf } from '../../../../components/Roadtrip/stayReading'
|
||||
import { useLeaveMode, type LeaveMode } from '../../../../components/Roadtrip/useLeaveMode'
|
||||
import { formatClockTime } from '../../../../utils/formatters'
|
||||
@@ -110,7 +111,9 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
const arrival = useMemo(() => {
|
||||
if (located) return located.entry?.arrival ?? null
|
||||
if (!stage || stopPlaceId == null) return null
|
||||
const index = stage.stops.findIndex(s => s.placeId === stopPlaceId)
|
||||
// A stored stop at the place: the hotel a day sets out from shares the place of the
|
||||
// hotel's own stop and comes first on the card, with a clock of its own.
|
||||
const index = stage.stops.findIndex(s => isStoredStop(s) && s.placeId === stopPlaceId)
|
||||
if (index === -1) return null
|
||||
return stage.schedule.entries[index]?.arrival ?? null
|
||||
}, [located, stage, stopPlaceId])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-134
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-147
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -3091,3 +3091,174 @@ describe('useTripPlanner road trip: the drive between two days (#2461)', () => {
|
||||
expect(rt.vias.reanchor).toHaveBeenLastCalledWith(5, { vias: [], remove: [9] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a booked night at the edge of a day', () => {
|
||||
const NO_VIA = 'The drive from or to your stay keeps its road.'
|
||||
const bookend = (phase: 'morning' | 'evening', over: Record<string, unknown>) => ({
|
||||
assignmentId: phase === 'morning' ? -6_000_000_010 : -6_000_000_011,
|
||||
placeId: 900,
|
||||
name: 'Hotel',
|
||||
stopType: 'hotel',
|
||||
bookend: { phase, accommodationId: 5, reservationId: null, checkingOut: phase === 'morning', checkingIn: phase === 'evening', checkOut: null },
|
||||
...over,
|
||||
})
|
||||
/**
|
||||
* A transfer day west to east: out of one hotel, two places, into the next. Four points
|
||||
* on one straight road, so every leg is a stretch of the line of its own.
|
||||
*/
|
||||
const transferDay = () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [
|
||||
bookend('morning', { ownerIndex: 0, lat: 53, lng: 9 }),
|
||||
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 10, name: 'Lookout' },
|
||||
{ assignmentId: 12, ownerIndex: 1, lat: 53, lng: 11, name: 'Falls' },
|
||||
bookend('evening', { ownerIndex: 2, lat: 53, lng: 12 }),
|
||||
],
|
||||
geometry: [[53, 9], [53, 10], [53, 11], [53, 12]],
|
||||
}]
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-140: a click on the drive out of the morning hotel puts no via there, and says why', async () => {
|
||||
transferDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(53, 9.5) })
|
||||
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
expect(toasts).toContainEqual({ message: NO_VIA, type: 'info' })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-141: nor on the drive into tonight’s hotel, while the drive between the places still takes one', async () => {
|
||||
transferDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(53, 11.5) })
|
||||
expect(rt.vias.add).not.toHaveBeenCalled()
|
||||
expect(toasts.filter(t => t.message === NO_VIA)).toHaveLength(1)
|
||||
|
||||
await act(async () => { await result.current.addRoadtripVia(53, 10.5) })
|
||||
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53, 10.5)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-142: a via dragged onto the drive from or to a hotel keeps the leg it was on', async () => {
|
||||
transferDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53, 9.5) })
|
||||
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53, 11.5) })
|
||||
|
||||
expect(rt.vias.move).toHaveBeenNthCalledWith(1, 5, 3, 53, 9.5, undefined)
|
||||
expect(rt.vias.move).toHaveBeenNthCalledWith(2, 5, 3, 53, 11.5, undefined)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-143: a place added on the drive out of the hotel comes first, one on the drive into the next comes last', async () => {
|
||||
transferDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// Card positions: after the morning hotel, and in front of the evening one.
|
||||
expect(result.current.manualStopTargetFor(53, 9.5)).toMatchObject({ dayId: 5, position: 1 })
|
||||
expect(result.current.manualStopTargetFor(53, 11.5)).toMatchObject({ dayId: 5, position: 3 })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-144: a place added between the first two places lands between them, not in front of the first', async () => {
|
||||
// The morning hotel shares the first place's index. Found by that index, it stood in
|
||||
// for the place, and the new one went in one stop early.
|
||||
transferDay()
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
expect(result.current.manualStopTargetFor(53, 10.5)).toMatchObject({ dayId: 5, position: 2 })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-145: behind a terminal, and on the road after the next place, a place lands where it fell', async () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
const carrier = (role: 'departure' | 'arrival') =>
|
||||
({ reservationId: 70, type: 'flight', role, title: 'LH 2020', code: null, at: null })
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [
|
||||
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 9, name: 'Hamburg' },
|
||||
{ assignmentId: -3000000140, placeId: -70, ownerIndex: 1, lat: 53, lng: 10, carrier: carrier('departure') },
|
||||
{ assignmentId: -3000000141, placeId: -70, ownerIndex: 1, lat: 53, lng: 11, carrier: carrier('arrival') },
|
||||
{ assignmentId: 12, ownerIndex: 1, lat: 53, lng: 12, name: 'Munich' },
|
||||
{ assignmentId: 13, ownerIndex: 2, lat: 53, lng: 13, name: 'Garmisch' },
|
||||
],
|
||||
geometry: [[53, 9], [53, 10], [53, 11], [53, 12], [53, 13]],
|
||||
}]
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
// Right behind the arrival, not between the two ends of the flight.
|
||||
expect(result.current.manualStopTargetFor(53, 11.5)).toMatchObject({ dayId: 5, position: 3 })
|
||||
// Behind Munich, which shares its index with the terminals seated in front of it.
|
||||
expect(result.current.manualStopTargetFor(53, 12.5)).toMatchObject({ dayId: 5, position: 4 })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-146: the inspector speaks for the stay’s own stop, whatever bookends stand at its place', async () => {
|
||||
seedTrip({ places: [buildPlace({ id: 900 })], days: [buildDay({ id: 5 }), buildDay({ id: 6 })] })
|
||||
rt.routes.days = [
|
||||
{
|
||||
dayId: 5,
|
||||
stops: [
|
||||
{ assignmentId: 21, placeId: 900, lat: 53, lng: 9, night: true },
|
||||
{ assignmentId: 22, placeId: 901, lat: 53, lng: 10 },
|
||||
bookend('evening', { ownerIndex: 2, lat: 53, lng: 9 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
dayId: 6,
|
||||
stops: [bookend('morning', { assignmentId: -6_000_000_012, ownerIndex: 0, lat: 53, lng: 9 }), { assignmentId: 23, placeId: 902, lat: 53, lng: 11 }],
|
||||
},
|
||||
]
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } }))
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.handlePlaceClick(900) })
|
||||
|
||||
// One stop answers for the place, so its day end can be set there.
|
||||
expect(result.current.roadtripEndDay?.active).toBe(false)
|
||||
await act(async () => result.current.roadtripEndDay?.onToggle())
|
||||
expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 5, 21, true)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-147: a way chosen for the first leg after the morning hotel is still that leg', async () => {
|
||||
const LINE: [number, number][] = [[53, 10], [53, 11]]
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [
|
||||
bookend('morning', { ownerIndex: 0, lat: 53, lng: 9 }),
|
||||
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 10, name: 'Lookout' },
|
||||
{ assignmentId: 12, ownerIndex: 1, lat: 53, lng: 11, name: 'Falls' },
|
||||
],
|
||||
legs: [{ distance: 70_000, duration: 3_600 }, { distance: 70_000, duration: 3_600 }],
|
||||
legLines: [[[53, 9], [53, 10]], LINE],
|
||||
geometry: [[53, 9], [53, 10], [53, 11]],
|
||||
}]
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0, 0, 53.1, 10.5)] }
|
||||
rt.alt.open = {
|
||||
dayId: 5, drive: { kind: 'leg', index: 1 }, loading: false, error: false, proving: null,
|
||||
anchor: { dayId: 5, afterIndex: 0 },
|
||||
ends: { from: 11, to: 12 },
|
||||
engine: 'osrm',
|
||||
route: rt.legRoute,
|
||||
routes: [
|
||||
{ coordinates: [[53, 10], [53.1, 10.5], [53, 11]], distance: 80_000, duration: 4_000, divergence: null, current: true },
|
||||
{ coordinates: LINE, distance: 70_000, duration: 3_600, divergence: null, direct: true },
|
||||
],
|
||||
}
|
||||
rt.legRoute.mockResolvedValue({ coordinates: LINE, distance: 70_000, duration: 3_600, fellBack: false })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.chooseRouteAlternative(1) })
|
||||
|
||||
expect(toasts.filter(t => t.message.includes('changed'))).toEqual([])
|
||||
expect(rt.vias.addMany).toHaveBeenCalledWith(5, [], [0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useAutomaticDayPoints } from '../../components/Roadtrip/useAutomaticDay
|
||||
import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { carrierReservationIds, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { carrierReservationIds, isStoredStop, viasLeaving, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||
@@ -1746,7 +1746,20 @@ export function useTripPlanner() {
|
||||
// (#2428). Nothing can be filed against a terminal: it stands in for no assignment,
|
||||
// so a via anchored to it would be stored at a position that belongs to the stop
|
||||
// after it and bend that stop's road instead. The callers decide what to refuse.
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | null = null
|
||||
//
|
||||
// `bookendLeg` says the drive leaves or reaches a booked night's hotel at the day's
|
||||
// edge, which files nothing either: the morning's hotel has no index of its own, and
|
||||
// the evening's drive is reached from the index that shapes the road into tomorrow.
|
||||
// `card` is where the point fell on the card it was measured on, the day and the index
|
||||
// of the stop before it there, -1 for the drive in before the card's first stop.
|
||||
let best: {
|
||||
dayId: number
|
||||
afterIndex: number
|
||||
offRouteKm: number
|
||||
terminal: CarrierTerminal['role'] | null
|
||||
bookendLeg: boolean
|
||||
card: { dayId: number; index: number }
|
||||
} | null = null
|
||||
for (const day of roadtripRoutes.days) {
|
||||
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored
|
||||
// on, but that day's stops are no longer all on the card of the same name: after a
|
||||
@@ -1782,7 +1795,14 @@ export function useTripPlanner() {
|
||||
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
|
||||
const owner = arrivedFrom.ownerDayId ?? day.dayId
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: arrivedFrom.ownerIndex ?? 0, offRouteKm: hit.offRouteKm, terminal: arrivedFrom.carrier?.role ?? null }
|
||||
best = {
|
||||
dayId: owner,
|
||||
afterIndex: arrivedFrom.ownerIndex ?? 0,
|
||||
offRouteKm: hit.offRouteKm,
|
||||
terminal: arrivedFrom.carrier?.role ?? null,
|
||||
bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend,
|
||||
card: { dayId: day.dayId, index: -1 },
|
||||
}
|
||||
continue
|
||||
}
|
||||
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
|
||||
@@ -1799,7 +1819,14 @@ export function useTripPlanner() {
|
||||
const owner = anchor.ownerDayId ?? day.dayId
|
||||
// A drag stays on its own day; a fresh click may land wherever it landed.
|
||||
if (onlyDayId !== undefined && owner !== onlyDayId) continue
|
||||
best = { dayId: owner, afterIndex: anchor.ownerIndex ?? at, offRouteKm: hit.offRouteKm, terminal: anchor.carrier?.role ?? null }
|
||||
best = {
|
||||
dayId: owner,
|
||||
afterIndex: anchor.ownerIndex ?? at,
|
||||
offRouteKm: hit.offRouteKm,
|
||||
terminal: anchor.carrier?.role ?? null,
|
||||
bookendLeg: !!anchor.bookend || !!day.stops[at + 1]?.bookend,
|
||||
card: { dayId: day.dayId, index: at },
|
||||
}
|
||||
}
|
||||
return best
|
||||
}, [roadtripRoutes.days])
|
||||
@@ -1823,8 +1850,17 @@ export function useTripPlanner() {
|
||||
// Nothing is stopped at on a flight. Behind an arrival terminal or a hire car's desk
|
||||
// is a road, and a stop there is the first stop after landing or after the pick-up.
|
||||
if (!anchor || anchor.terminal === 'departure') return null
|
||||
// Behind a terminal, and on the drive from the hotel a day sets out from or to the one
|
||||
// it ends at, the place goes where it fell on the card. None of them is a stored stop
|
||||
// to be found by its index, which each shares with one: after the morning's hotel is
|
||||
// before the day's first stop, before the evening's is after its last.
|
||||
if (anchor.bookendLeg || anchor.terminal) {
|
||||
return { dayId: anchor.card.dayId, position: anchor.card.index + 1, offRouteKm: anchor.offRouteKm }
|
||||
}
|
||||
for (const day of roadtripRoutes.days) {
|
||||
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
// The stored stop the anchor names, not a terminal or a hotel seated in front of it
|
||||
// with the same index, which put the place one stop early.
|
||||
const at = day.stops.findIndex(stop => isStoredStop(stop) && stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm }
|
||||
}
|
||||
// No card draws that stop, which happens while the rail is between rebuilds. Its own
|
||||
@@ -1911,6 +1947,9 @@ export function useTripPlanner() {
|
||||
// ride, or on the road out of a terminal: a via is filed by the position of a stored
|
||||
// stop, and a terminal is not one.
|
||||
if (!best || best.offRouteKm > 2 || best.terminal) return
|
||||
// Nor on the drive from or to a booked night's hotel, which says so rather than
|
||||
// ignoring the click (`legReroutable`).
|
||||
if (best.bookendLeg) { toast.info(t('roadtrip.bookend.noVia')); return }
|
||||
try {
|
||||
await roadtripVias.add(best.dayId, best.afterIndex, lat, lng)
|
||||
} catch (err: unknown) {
|
||||
@@ -1930,7 +1969,7 @@ export function useTripPlanner() {
|
||||
// just says which leg gets bent, and the router answers the rest.
|
||||
const anchor = anchorFor(lat, lng, dayId)
|
||||
try {
|
||||
await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal ? anchor.afterIndex : undefined)
|
||||
await roadtripVias.move(dayId, id, lat, lng, anchor && !anchor.terminal && !anchor.bookendLeg ? anchor.afterIndex : undefined)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
@@ -2653,8 +2692,11 @@ export function useTripPlanner() {
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
// The stops the inspector speaks for. A booked night at a day's edge stands on the
|
||||
// hotel's place without being a stop of the day, so it is left out: counted, the hotel's
|
||||
// own stop lost its stay and its day end to a second match.
|
||||
const selectedRoadtripStops = roadtripRoutes.days.flatMap(day => day.stops).filter(stop =>
|
||||
!stop.automaticNight && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId),
|
||||
!stop.automaticNight && !stop.bookend && (selectedAssignmentId ? stop.assignmentId === selectedAssignmentId : stop.placeId === selectedPlaceId),
|
||||
)
|
||||
const endDayStop = selectedRoadtripStops.length === 1 ? selectedRoadtripStops[0] : undefined
|
||||
const roadtripEndDay = roadtripActive && dailyTimesActive && can('day_edit', trip) && endDayStop && endDayStop.assignmentId > 0
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRou
|
||||
import { openLeg } from '../../../helpers/legAlternatives'
|
||||
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
|
||||
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-042
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-043
|
||||
//
|
||||
// The stage's pins come out of the trip store rather than the planner's map list, so the
|
||||
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
|
||||
@@ -889,3 +889,35 @@ describe('MMapArea booking routes on the day-scoped plan map (#2456)', () => {
|
||||
expect(drawnBookingIds()).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MMapArea and a booked night at the edge of the stage', () => {
|
||||
it('FE-MOB-MAPAREA-043: a pin at the hotel opens its stored stop, never the bookend in front of it, else the inspector', () => {
|
||||
const base = drivePlanner(3)
|
||||
const [before, loop] = base.roadtripRoutes.days
|
||||
const hotel = (placeId: number) => ({
|
||||
...loop.stops[0],
|
||||
assignmentId: -6_000_000_006,
|
||||
placeId,
|
||||
ownerIndex: 0,
|
||||
stopType: 'hotel',
|
||||
bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
})
|
||||
const withBookend = (placeId: number) => ({
|
||||
...base,
|
||||
roadtripRoutes: { ...base.roadtripRoutes, days: [before, { ...loop, stops: [hotel(placeId), ...loop.stops] }] },
|
||||
}) as TripPlanner
|
||||
const shell = stageShell()
|
||||
const { unmount } = render(<MMapArea planner={withBookend(11)} shell={shell} />)
|
||||
tapPin(11)
|
||||
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
|
||||
unmount()
|
||||
|
||||
// A hotel the drive has as its bookend only: the road trip has no stop to open.
|
||||
const only = withBookend(99)
|
||||
const other = stageShell()
|
||||
render(<MMapArea planner={only} shell={other} />)
|
||||
tapPin(99)
|
||||
expect(other.openSheet).not.toHaveBeenCalled()
|
||||
expect(only.handleMarkerClick).toHaveBeenCalledWith(99)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import {
|
||||
RtAutoRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome,
|
||||
RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome,
|
||||
} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
|
||||
import type { RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
|
||||
import type { BookendReading, RoadtripRow, StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
|
||||
import type { CarrierTerminal, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../src/types'
|
||||
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
|
||||
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-056
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-058
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
@@ -801,3 +801,45 @@ describe('RtSpillRow', () => {
|
||||
expect(screen.queryByText('roadtrip.spill.departs:21:30')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtBookendRow', () => {
|
||||
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
|
||||
phase: 'morning',
|
||||
variant: 'checkOut',
|
||||
name: 'Hotel Alpenblick',
|
||||
until: '10:00',
|
||||
reservationId: 41,
|
||||
accommodationId: 5,
|
||||
placeId: 900,
|
||||
...over,
|
||||
})
|
||||
const row = (over: Partial<StopRow> = {}) =>
|
||||
stopRow({ number: null, service: true, time: '08:40', stop: { ...stopRow().stop, name: 'Hotel Alpenblick', stopType: 'hotel' }, ...over })
|
||||
|
||||
it('FE-MOB-RTROW-057: says which night it is on the bed a stay wears, with no number, and opens on a tap', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { container } = render(<RtBookendRow row={row()} bookend={reading()} chrome={chrome} onOpen={onOpen} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('08:40')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-bed-double')).not.toBeNull()
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' })
|
||||
expect(onOpen).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-058: an evening says nothing under its name but what the drive into it ran over', () => {
|
||||
const warning: ScheduleWarning = { index: 3, code: 'leg', overMinutes: 30 }
|
||||
const quiet = render(<RtBookendRow row={row({ time: null })} bookend={reading({ phase: 'evening', variant: 'back', until: null })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.stay\.until/)).toBeNull()
|
||||
expect(screen.queryByText('08:40')).toBeNull()
|
||||
quiet.unmount()
|
||||
|
||||
render(<RtBookendRow row={row({ warning, time: '19:10' })} bookend={reading({ phase: 'evening', variant: 'back', until: null })} chrome={{ ...chrome, is12h: true }} onOpen={vi.fn()} />)
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('7:10 PM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-061
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-064
|
||||
//
|
||||
// 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.
|
||||
@@ -937,6 +937,77 @@ describe('MRoadtripTab', () => {
|
||||
fireEvent.click(within(notice).getByRole('button', { name: 'roadtrip.ride.open' }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 70 })
|
||||
})
|
||||
|
||||
describe('a booked night at the edge of the stage', () => {
|
||||
const bookend = (phase: 'morning' | 'evening', ownerIndex: number): RoadtripStop => ({
|
||||
...real(phase === 'morning' ? -6_000_000_004 : -6_000_000_005, 900, 'Hotel Alpenblick'),
|
||||
ownerIndex,
|
||||
lat: 47.2,
|
||||
lng: 11.4,
|
||||
stopType: 'hotel',
|
||||
bookend: { phase, accommodationId: 5, reservationId: 41, checkingOut: phase === 'morning', checkingIn: false, checkOut: phase === 'morning' ? '10:00' : null },
|
||||
})
|
||||
/** Out of the hotel on its check-out morning, two places, and into the night's hotel. */
|
||||
const hotelStage = (first: RoadtripStop[] = []) => stage({
|
||||
stops: [...first, bookend('morning', 0), real(503, 103, 'Kyoto Station'), { ...real(505, 105, 'Munich'), ownerIndex: 1 }, bookend('evening', 2)],
|
||||
legs: [...first.map((): RouteSegment => ({ ...seg('', ''), distance: 0, from: [47.2, 11.4], to: [47.2, 11.4] })), seg('12 km', '20 min'), seg('30 km', '35 min'), seg('40 km', '45 min')],
|
||||
schedule: {
|
||||
entries: [...first.map(() => entry('08:00')), entry('08:40'), entry('09:00', '09:30'), entry('10:05'), entry('10:50')],
|
||||
warnings: [],
|
||||
},
|
||||
legVias: [[], [], [], []], driveWarnings: [], spills: [], dryPoints: [],
|
||||
})
|
||||
const hotelBooking = booking({ id: 41, title: 'Hotel Alpenblick', type: 'hotel', day_id: 1 })
|
||||
|
||||
it('FE-MOB-RTTAB-062: the stage leaves from the hotel and comes back to one, with no number and no kind on either', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [hotelStage()] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick')).toBeInTheDocument()
|
||||
// Only the two places offer to become a stop on the way, and the count says two.
|
||||
expect(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })).toHaveLength(2)
|
||||
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
// The stage starts when the hotel is left and ends when the next one is reached.
|
||||
const start = screen.getByText('mobileTrip.rtStart').parentElement as HTMLElement
|
||||
expect(within(start).getByText('08:40')).toBeInTheDocument()
|
||||
const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
|
||||
expect(within(arrive).getByText('10:50')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-063: a tap opens the booking behind the night, or the stay for somebody who may not edit bookings', () => {
|
||||
const editor = planner({ roadtripRoutes: routes({ days: [hotelStage()] }), reservations: [hotelBooking] })
|
||||
const first = renderTab(editor)
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.back:Hotel Alpenblick'))
|
||||
expect(editor.setEditingReservation).toHaveBeenCalledWith(hotelBooking)
|
||||
expect(editor.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
expect(first.shell.openSheet).not.toHaveBeenCalled()
|
||||
first.unmount()
|
||||
|
||||
const dayEditor = planner({
|
||||
roadtripRoutes: routes({ days: [hotelStage()] }),
|
||||
reservations: [hotelBooking],
|
||||
can: (action: string) => action !== 'reservation_edit',
|
||||
})
|
||||
const { shell } = renderTab(dayEditor)
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 5 })
|
||||
expect(dayEditor.setEditingReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-064: a reader gets the hotel as a place, and the morning marker there is the hotel row', () => {
|
||||
const reader = planner({
|
||||
roadtripRoutes: routes({ days: [hotelStage([{ ...bookend('morning', 0), assignmentId: -2_000_000_003, bookend: undefined, automaticNight: { phase: 'start', fromDayNumber: 1 } } as RoadtripStop])] }),
|
||||
can: vi.fn(() => false),
|
||||
})
|
||||
const { shell } = renderTab(reader)
|
||||
|
||||
expect(screen.queryByText('roadtrip.window.resume')).toBeNull()
|
||||
fireEvent.click(screen.getByText('roadtrip.bookend.checkOut:Hotel Alpenblick'))
|
||||
expect(reader.handlePlaceClick).toHaveBeenCalledWith(900)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('day swipe', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-025
|
||||
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-026
|
||||
//
|
||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||
// in English.
|
||||
@@ -322,3 +322,29 @@ describe('MRtStaySheet', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MRtStaySheet and a booked night at the edge of the stage', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-026: the arrival is the stop of the place, not the hotel the day sets out from there', () => {
|
||||
// The morning's hotel stands on Bremen's place at the head of the card, with a clock of
|
||||
// its own. Found by the place, it put 07:10 in the preview for a stop reached at 22:30.
|
||||
const hotel = {
|
||||
...STAGE.stops[1],
|
||||
assignmentId: -6_000_000_022,
|
||||
ownerIndex: 0,
|
||||
stopType: 'hotel',
|
||||
bookend: { phase: 'morning', accommodationId: 5, reservationId: null, checkingOut: false, checkingIn: false, checkOut: null },
|
||||
}
|
||||
const withHotel = {
|
||||
...STAGE,
|
||||
stops: [hotel, ...STAGE.stops],
|
||||
schedule: { entries: [{ arrival: '07:10', departure: '07:10', anchored: false, dayOffset: 0 }, ...STAGE.schedule.entries], warnings: [] },
|
||||
} as unknown as RoadtripDay
|
||||
renderSheet({ roadtripRoutes: { days: [withHotel] } }, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } } })
|
||||
expect(screen.getByText('22:30')).toBeInTheDocument()
|
||||
expect(screen.queryByText('07:10')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
carrierLegsFor,
|
||||
carrierSeam,
|
||||
foldRouteRun,
|
||||
isStationaryJoin,
|
||||
splitIntoRuns,
|
||||
spillChains,
|
||||
dayWindow,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
formatDurationShort,
|
||||
seatCarrierStops,
|
||||
undatedRides,
|
||||
viasLeaving,
|
||||
viasOnLeg,
|
||||
type CarrierBooking,
|
||||
type CarrierSeam,
|
||||
type DistanceUnit,
|
||||
@@ -269,8 +270,9 @@ export class RoadtripPlanService {
|
||||
stops.forEach((stop, index) => {
|
||||
stopAt.push(points.length);
|
||||
points.push({ lat: stop.lat, lng: stop.lng });
|
||||
// None bend the drive from or to a booked night's hotel (`viasOnLeg`).
|
||||
if (index < stops.length - 1)
|
||||
points.push(...viasLeaving(stop, context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
|
||||
points.push(...viasOnLeg(stop, stops[index + 1], context.vias).map((v) => ({ lat: v.lat, lng: v.lng })));
|
||||
});
|
||||
try {
|
||||
if (points.length > 100) throw new Error('Too many waypoints');
|
||||
@@ -336,7 +338,8 @@ export class RoadtripPlanService {
|
||||
const pairs = chain.stops.slice(0, -1).map((from, index) => ({ from, to: chain.stops[index + 1] }));
|
||||
if (connectDays && previous && chain.stops.length) pairs.push({ from: previous, to: chain.stops[0] });
|
||||
for (const { from, to } of pairs) {
|
||||
if (asked.has(stopKey(from) + '>' + stopKey(to))) continue;
|
||||
// A night spent at one hotel is no drive, and no router is asked about it.
|
||||
if (isStationaryJoin(from, to) || asked.has(stopKey(from) + '>' + stopKey(to))) continue;
|
||||
await fetchRun(
|
||||
[from, to],
|
||||
chain.dayId,
|
||||
|
||||
@@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'فتح الحجز',
|
||||
'roadtrip.ride.undated': 'الحجز {title} لا يقع في أي يوم من أيام هذه الرحلة، لذلك لا يستخدمه المسار.',
|
||||
'roadtrip.bookings.loose': 'محجوز أيضًا في هذا اليوم',
|
||||
'roadtrip.bookend.checkOut': 'تسجيل المغادرة · {name}',
|
||||
'roadtrip.bookend.from': 'من {name}',
|
||||
'roadtrip.bookend.back': 'العودة إلى {name}',
|
||||
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
|
||||
'roadtrip.bookend.noVia': 'يبقى الطريق من مكان إقامتك وإليه كما هو.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'A reserva {title} não está em nenhum dia desta viagem, por isso o percurso não a considera.',
|
||||
'roadtrip.bookings.loose': 'Também reservado neste dia',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Saindo de {name}',
|
||||
'roadtrip.bookend.back': 'Volta para {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'O trajeto de ida e volta da sua hospedagem mantém o próprio caminho.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La reserva {title} no és a cap dia d’aquest viatge, així que el trajecte no la té en compte.',
|
||||
'roadtrip.bookings.loose': 'També reservat aquest dia',
|
||||
'roadtrip.bookend.checkOut': 'Sortida · {name}',
|
||||
'roadtrip.bookend.from': 'Des de {name}',
|
||||
'roadtrip.bookend.back': 'Tornada a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Entrada · {name}',
|
||||
'roadtrip.bookend.noVia': 'El trajecte des del teu allotjament o fins a ell manté la seva ruta.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Otevřít rezervaci',
|
||||
'roadtrip.ride.undated': 'Rezervace {title} nepatří k žádnému dni této cesty, proto s ní jízda nepočítá.',
|
||||
'roadtrip.bookings.loose': 'Také rezervováno na tento den',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Z ubytování {name}',
|
||||
'roadtrip.bookend.back': 'Zpět do ubytování {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'Trasa z vašeho ubytování a do něj zůstává beze změny.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Die Buchung {title} liegt auf keinem Tag dieser Reise, deshalb plant die Fahrt nicht mit ihr.',
|
||||
'roadtrip.bookings.loose': 'Außerdem an diesem Tag gebucht',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Ab {name}',
|
||||
'roadtrip.bookend.back': 'Zurück zu {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'Die Fahrt von und zu deiner Unterkunft behält ihre Strecke.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -283,5 +283,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Open booking',
|
||||
'roadtrip.ride.undated': '{title} is on none of this trip’s days, so the drive does not use it.',
|
||||
'roadtrip.bookings.loose': 'Also booked this day',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'From {name}',
|
||||
'roadtrip.bookend.back': 'Back to {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'The drive from or to your stay keeps its road.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La reserva {title} no está en ningún día de este viaje, así que el trayecto no la tiene en cuenta.',
|
||||
'roadtrip.bookings.loose': 'También reservado este día',
|
||||
'roadtrip.bookend.checkOut': 'Salida · {name}',
|
||||
'roadtrip.bookend.from': 'Desde {name}',
|
||||
'roadtrip.bookend.back': 'Vuelta a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Entrada · {name}',
|
||||
'roadtrip.bookend.noVia': 'El trayecto desde tu alojamiento o hasta él mantiene su ruta.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -292,5 +292,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La réservation {title} n’est sur aucun jour de ce voyage, le trajet n’en tient donc pas compte.',
|
||||
'roadtrip.bookings.loose': 'Également réservé ce jour',
|
||||
'roadtrip.bookend.checkOut': 'Départ · {name}',
|
||||
'roadtrip.bookend.from': 'Depuis {name}',
|
||||
'roadtrip.bookend.back': 'Retour à {name}',
|
||||
'roadtrip.bookend.checkIn': 'Arrivée · {name}',
|
||||
'roadtrip.bookend.noVia': 'Le trajet depuis ou vers votre hébergement garde son itinéraire.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -294,5 +294,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Η κράτηση {title} δεν βρίσκεται σε καμία μέρα αυτού του ταξιδιού, οπότε η διαδρομή δεν τη λαμβάνει υπόψη.',
|
||||
'roadtrip.bookings.loose': 'Επίσης κρατημένο αυτή τη μέρα',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Από {name}',
|
||||
'roadtrip.bookend.back': 'Επιστροφή: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'Η διαδρομή προς και από το κατάλυμα διατηρεί τον δρόμο της.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Foglalás megnyitása',
|
||||
'roadtrip.ride.undated': '{title}: a foglalás az utazás egyik napjára sem esik, ezért az útvonal nem számol vele.',
|
||||
'roadtrip.bookings.loose': 'Erre a napra még lefoglalva',
|
||||
'roadtrip.bookend.checkOut': 'Kijelentkezés · {name}',
|
||||
'roadtrip.bookend.from': 'Indulás: {name}',
|
||||
'roadtrip.bookend.back': 'Vissza: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
|
||||
'roadtrip.bookend.noVia': 'A szálláshoz vezető és onnan induló út útvonala nem változik.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Pemesanan {title} tidak berada di hari mana pun dalam perjalanan ini, jadi perjalanan tidak memperhitungkannya.',
|
||||
'roadtrip.bookings.loose': 'Juga dipesan hari ini',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Dari {name}',
|
||||
'roadtrip.bookend.back': 'Kembali ke {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'Perjalanan dari dan ke penginapan Anda tetap pada jalurnya.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'La prenotazione {title} non cade in nessun giorno di questo viaggio, quindi il percorso non la considera.',
|
||||
'roadtrip.bookings.loose': 'Prenotato anche questo giorno',
|
||||
'roadtrip.bookend.checkOut': 'Check-out · {name}',
|
||||
'roadtrip.bookend.from': 'Da {name}',
|
||||
'roadtrip.bookend.back': 'Ritorno a {name}',
|
||||
'roadtrip.bookend.checkIn': 'Check-in · {name}',
|
||||
'roadtrip.bookend.noVia': 'Il tragitto da e verso il tuo alloggio mantiene il suo percorso.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -285,5 +285,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '予約を開く',
|
||||
'roadtrip.ride.undated': '{title} はこの旅行のどの日にも入っていないため、走行では使われません。',
|
||||
'roadtrip.bookings.loose': 'この日のその他の予約',
|
||||
'roadtrip.bookend.checkOut': 'チェックアウト · {name}',
|
||||
'roadtrip.bookend.from': '{name}から',
|
||||
'roadtrip.bookend.back': '{name}へ戻る',
|
||||
'roadtrip.bookend.checkIn': 'チェックイン · {name}',
|
||||
'roadtrip.bookend.noVia': '宿泊先との間の区間は、今のルートのままになります。',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -282,5 +282,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '예약 열기',
|
||||
'roadtrip.ride.undated': '{title}은(는) 이 여행의 어느 날에도 속하지 않아 주행에 반영되지 않습니다.',
|
||||
'roadtrip.bookings.loose': '이날의 다른 예약',
|
||||
'roadtrip.bookend.checkOut': '체크아웃 · {name}',
|
||||
'roadtrip.bookend.from': '{name}에서 출발',
|
||||
'roadtrip.bookend.back': '{name}(으)로 돌아가기',
|
||||
'roadtrip.bookend.checkIn': '체크인 · {name}',
|
||||
'roadtrip.bookend.noVia': '숙소를 오가는 구간은 지금 경로를 그대로 유지합니다.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -290,5 +290,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'De boeking {title} valt op geen enkele dag van deze reis, dus de rit houdt er geen rekening mee.',
|
||||
'roadtrip.bookings.loose': 'Ook geboekt op deze dag',
|
||||
'roadtrip.bookend.checkOut': 'Uitchecken · {name}',
|
||||
'roadtrip.bookend.from': 'Vanaf {name}',
|
||||
'roadtrip.bookend.back': 'Terug naar {name}',
|
||||
'roadtrip.bookend.checkIn': 'Inchecken · {name}',
|
||||
'roadtrip.bookend.noVia': 'De rit van en naar je verblijf houdt zijn route.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Otwórz rezerwację',
|
||||
'roadtrip.ride.undated': 'Rezerwacja {title} nie przypada na żaden dzień tej podróży, więc trasa jej nie uwzględnia.',
|
||||
'roadtrip.bookings.loose': 'Zarezerwowane też tego dnia',
|
||||
'roadtrip.bookend.checkOut': 'Wymeldowanie · {name}',
|
||||
'roadtrip.bookend.from': 'Wyjazd: {name}',
|
||||
'roadtrip.bookend.back': 'Powrót: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
|
||||
'roadtrip.bookend.noVia': 'Trasa do noclegu i z niego pozostaje bez zmian.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -291,5 +291,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'Бронирование {title} не относится ни к одному дню этой поездки, поэтому маршрут его не учитывает.',
|
||||
'roadtrip.bookings.loose': 'Также забронировано в этот день',
|
||||
'roadtrip.bookend.checkOut': 'Выезд · {name}',
|
||||
'roadtrip.bookend.from': 'Отправление: {name}',
|
||||
'roadtrip.bookend.back': 'Возвращение: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Заезд · {name}',
|
||||
'roadtrip.bookend.noVia': 'Маршрут от места проживания и обратно к нему остаётся прежним.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -286,5 +286,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Öppna bokning',
|
||||
'roadtrip.ride.undated': 'Bokningen {title} ligger inte på någon av resans dagar, så körningen tar inte med den.',
|
||||
'roadtrip.bookings.loose': 'Även bokat denna dag',
|
||||
'roadtrip.bookend.checkOut': 'Utcheckning · {name}',
|
||||
'roadtrip.bookend.from': 'Från {name}',
|
||||
'roadtrip.bookend.back': 'Tillbaka till {name}',
|
||||
'roadtrip.bookend.checkIn': 'Incheckning · {name}',
|
||||
'roadtrip.bookend.noVia': 'Resan till och från ditt boende behåller sin väg.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -289,5 +289,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.undated':
|
||||
'{title} rezervasyonu bu yolculuğun hiçbir gününde değil, bu yüzden sürüş onu hesaba katmıyor.',
|
||||
'roadtrip.bookings.loose': 'Bu gün için ayrıca rezerve edildi',
|
||||
'roadtrip.bookend.checkOut': 'Çıkış · {name}',
|
||||
'roadtrip.bookend.from': 'Başlangıç: {name}',
|
||||
'roadtrip.bookend.back': 'Dönüş: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Giriş · {name}',
|
||||
'roadtrip.bookend.noVia': 'Konaklama yerinize gidiş ve dönüş yolu değişmez.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -288,5 +288,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Відкрити бронювання',
|
||||
'roadtrip.ride.undated': 'Бронювання {title} не припадає на жоден день цієї подорожі, тому маршрут його не враховує.',
|
||||
'roadtrip.bookings.loose': 'Також заброньовано цього дня',
|
||||
'roadtrip.bookend.checkOut': 'Виїзд · {name}',
|
||||
'roadtrip.bookend.from': 'Відправлення: {name}',
|
||||
'roadtrip.bookend.back': 'Повернення: {name}',
|
||||
'roadtrip.bookend.checkIn': 'Заїзд · {name}',
|
||||
'roadtrip.bookend.noVia': 'Маршрут від місця проживання й назад до нього лишається незмінним.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -287,5 +287,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': 'Mở đặt chỗ',
|
||||
'roadtrip.ride.undated': 'Đặt chỗ {title} không thuộc ngày nào của chuyến đi này, nên hành trình không tính đến nó.',
|
||||
'roadtrip.bookings.loose': 'Cũng đã đặt trong ngày này',
|
||||
'roadtrip.bookend.checkOut': 'Trả phòng · {name}',
|
||||
'roadtrip.bookend.from': 'Từ {name}',
|
||||
'roadtrip.bookend.back': 'Về lại {name}',
|
||||
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
|
||||
'roadtrip.bookend.noVia': 'Chặng đi và về nơi lưu trú giữ nguyên tuyến đường.',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -272,5 +272,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '開啟預訂',
|
||||
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不會用到它。',
|
||||
'roadtrip.bookings.loose': '當天的其他預訂',
|
||||
'roadtrip.bookend.checkOut': '退房 · {name}',
|
||||
'roadtrip.bookend.from': '從 {name} 出發',
|
||||
'roadtrip.bookend.back': '返回 {name}',
|
||||
'roadtrip.bookend.checkIn': '入住 · {name}',
|
||||
'roadtrip.bookend.noVia': '往返住宿地點的路段維持原本的路線。',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -273,5 +273,10 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.ride.open': '打开预订',
|
||||
'roadtrip.ride.undated': '{title} 不在本次旅行的任何一天,因此行程不会用到它。',
|
||||
'roadtrip.bookings.loose': '当天的其他预订',
|
||||
'roadtrip.bookend.checkOut': '退房 · {name}',
|
||||
'roadtrip.bookend.from': '从 {name} 出发',
|
||||
'roadtrip.bookend.back': '返回 {name}',
|
||||
'roadtrip.bookend.checkIn': '入住 · {name}',
|
||||
'roadtrip.bookend.noVia': '往返住处的路段保持原有路线。',
|
||||
};
|
||||
export default roadtrip;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* ROADTRIP-ASSEMBLE-001..016: what the assembler does with the model's warnings, and
|
||||
* what it tells a surface about the drive between connected days.
|
||||
* ROADTRIP-ASSEMBLE-001..022: what the assembler does with the model's warnings, and
|
||||
* what it tells a surface about the drive between connected days. From 017 on: a booked
|
||||
* night seated at both ends of its days (`seatNightBookends`) is one spot overnight,
|
||||
* driven to and from like any stop and never between.
|
||||
*
|
||||
* The model itself is pinned by roadtripModel.spec.ts. This file pins the
|
||||
* wrapper, which is where the arguments are chosen: `deriveDriveWarnings` takes
|
||||
@@ -12,7 +14,9 @@
|
||||
*/
|
||||
import { assembleRoadtrip } from './assemble';
|
||||
import type { DayWindow } from './dayWindow';
|
||||
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import { seatNightBookends } from './nightBookends';
|
||||
import type { PlanDay, RoadtripStop, RoutedLeg, SnappedWaypoint } from './planning-types';
|
||||
import { standsAsDay } from './roadtripModel';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -379,3 +383,149 @@ describe('assembleRoadtrip booked nights', () => {
|
||||
expect(schedule.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleRoadtrip nights at a booked hotel', () => {
|
||||
// Three nights in one hotel from day 1, places on days 1 and 2, the check-out on day 3
|
||||
// with nothing of its own.
|
||||
const days = [1, 2, 3].map((id) => ({ id, day_number: id }));
|
||||
const hotelStay = {
|
||||
id: 5,
|
||||
place_id: 900,
|
||||
start_day_id: 1,
|
||||
end_day_id: 3,
|
||||
place_lat: 45,
|
||||
place_lng: 7,
|
||||
place_name: 'Hotel',
|
||||
check_out: '10:00',
|
||||
reservation_id: null,
|
||||
};
|
||||
const checkIn = stop({ ownerIndex: 0, lat: 45, lng: 7, night: true, stopType: 'hotel', checkInTime: '15:00' });
|
||||
const stored: PlanDay[] = [
|
||||
{
|
||||
dayId: 1,
|
||||
dayNumber: 1,
|
||||
date: '2026-06-01',
|
||||
title: null,
|
||||
stops: [checkIn, stop({ ownerIndex: 1 }), stop({ ownerIndex: 2 })],
|
||||
},
|
||||
{
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
date: '2026-06-02',
|
||||
title: null,
|
||||
stops: [
|
||||
stop({ ownerDayId: 2, ownerIndex: 0, assignmentId: 300, placeId: 400, lat: 60 }),
|
||||
stop({ ownerDayId: 2, ownerIndex: 1, assignmentId: 301, placeId: 401, lat: 61 }),
|
||||
],
|
||||
},
|
||||
{ dayId: 3, dayNumber: 3, date: '2026-06-03', title: null, stops: [] },
|
||||
];
|
||||
const seated = seatNightBookends(stored, days, [hotelStay]);
|
||||
const evening = seated[0]!.stops[3]!;
|
||||
const morning = seated[1]!.stops[0]!;
|
||||
const hotelKey = stopKey(checkIn);
|
||||
|
||||
function assembleNights(
|
||||
options: { connectDays?: boolean; window?: DayWindow | null; snaps?: Record<string, SnappedWaypoint> } = {},
|
||||
) {
|
||||
const allLegs: Record<string, RoutedLeg> = {};
|
||||
for (const day of seated) {
|
||||
day.stops.slice(0, -1).forEach((from, i) => {
|
||||
allLegs[legKey(from, day.stops[i + 1]!)] = leg(100);
|
||||
});
|
||||
}
|
||||
// A drive fetched for the hotel and itself some other time: the night is none.
|
||||
allLegs[legKey(evening, morning)] = leg(172);
|
||||
return assembleRoadtrip({
|
||||
plan: seated.filter((d) => standsAsDay(d.stops)),
|
||||
quietDays: seated.filter((d) => !standsAsDay(d.stops)),
|
||||
window: options.window ?? null,
|
||||
distanceUnit: 'metric',
|
||||
allLegs,
|
||||
snapByDay: options.snaps ? { 1: options.snaps } : {},
|
||||
missedByDay: {},
|
||||
loading: false,
|
||||
limits: { rangeKm: null, legMinutes: null, dayMinutes: null },
|
||||
vehicleKind: null,
|
||||
connectDays: options.connectDays ?? false,
|
||||
boundaries: [],
|
||||
labels: { start: 'start', end: 'end' },
|
||||
});
|
||||
}
|
||||
const names = (stops: RoadtripStop[]) => stops.map((s) => s.bookend?.phase ?? s.automaticNight?.phase ?? s.name);
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-017: connected days draw no band, no line and no figure across a night at one hotel', () => {
|
||||
const routes = assembleNights({ connectDays: true });
|
||||
const second = routes.days[1]!;
|
||||
expect(second.arrivingLeg).toBeUndefined();
|
||||
expect(second.arrivingLine).toBeUndefined();
|
||||
expect(second.arrivingFrom).toBeUndefined();
|
||||
// Three legs a day, the drive to the hotel and from it included, and nothing between.
|
||||
expect(routes.lines).toHaveLength(6);
|
||||
expect(routes.lineJoins).toEqual([false, false, false, false, false, false]);
|
||||
expect(routes.segments).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-018: the drives to and from the hotel count, the hotel is no destination', () => {
|
||||
const routes = assembleNights({ connectDays: true });
|
||||
expect(routes.days.map((d) => d.distance)).toEqual([300_000, 300_000]);
|
||||
expect(routes.totalDistance).toBe(600_000);
|
||||
// The four places; the stay's own stop and its bookends are the hotel, which is not counted.
|
||||
expect(routes.totalStops).toBe(4);
|
||||
// The check-out day with nothing of its own is not driven, and holds no hotel either.
|
||||
expect(routes.days.map((d) => d.dayId)).toEqual([1, 2]);
|
||||
expect(routes.quietDays.flatMap((d) => d.stops)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-019: a quiet day lists no hotel, even one left alone on it', () => {
|
||||
// Where every stop after the morning's hotel went on past midnight, the hotel is all
|
||||
// that is left on the card: nothing anybody could move to another day.
|
||||
const alone: PlanDay = { ...seated[1]!, stops: [morning] };
|
||||
const routes = assembleRoadtrip({
|
||||
plan: [alone],
|
||||
quietDays: [],
|
||||
window: null,
|
||||
distanceUnit: 'metric',
|
||||
allLegs: {},
|
||||
snapByDay: {},
|
||||
missedByDay: {},
|
||||
loading: false,
|
||||
limits: { rangeKm: null, legMinutes: null, dayMinutes: null },
|
||||
vehicleKind: null,
|
||||
connectDays: false,
|
||||
boundaries: [],
|
||||
labels: { start: 'start', end: 'end' },
|
||||
});
|
||||
expect(routes.days).toEqual([]);
|
||||
expect(routes.quietDays).toEqual([expect.objectContaining({ dayId: 2, stops: [] })]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-020: a day window runs the night at the hotel without a gap and ends the day there', () => {
|
||||
const routes = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } });
|
||||
expect(routes.dayWindowIssue).toBeNull();
|
||||
const [first, second] = routes.days;
|
||||
// The drive to the hotel is not cut at 18:00: the night is booked there.
|
||||
expect(names(first!.stops)).toEqual(['Stop 0', 'Stop 1', 'Stop 2', 'evening', 'end']);
|
||||
expect(first!.stops[4]!).toMatchObject({ lat: 45, lng: 7 });
|
||||
expect(first!.stops[4]!.bookend).toBeUndefined();
|
||||
expect(names(second!.stops)).toEqual(['start', 'morning', 'Stop 0', 'Stop 1', 'evening']);
|
||||
expect(second!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-021: a boundary can only be dragged along a drive between two stored stops', () => {
|
||||
const path = assembleNights({ window: { start: 8 * 60, end: 18 * 60 } }).boundaryPath ?? [];
|
||||
expect(path.map((p) => [p.from.assignmentId, p.to.assignmentId])).toEqual([
|
||||
[100, 101],
|
||||
[101, 102],
|
||||
[300, 301],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-ASSEMBLE-022: the walk from the road to the hotel is drawn once, however often the days stand there', () => {
|
||||
const snap: SnappedWaypoint = { asked: [45, 7], at: [45.001, 7], meters: 110 };
|
||||
const routes = assembleNights({ connectDays: true, snaps: { [hotelKey]: snap } });
|
||||
expect(routes.accessLines.filter((a) => a.stopKey === hotelKey)).toHaveLength(1);
|
||||
// Every stop there still says how far the road is.
|
||||
expect(routes.days[1]!.stops[0]!.offRoadMeters).toBe(110);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { carriesTheCar, isCarrierMode, isPickupStop } from './carriers';
|
||||
import { pointAtMeters } from './corridor';
|
||||
import type { RoadtripDayBoundary } from './day-boundary.schema';
|
||||
import { planDayWindow, type DayWindow } from './dayWindow';
|
||||
import { isStationaryJoin, withStationaryJoins } from './nightBookends';
|
||||
import { spillChains } from './nightSpill';
|
||||
import type {
|
||||
PlanDay,
|
||||
@@ -63,11 +64,13 @@ export function assembleRoadtrip({
|
||||
boundaries: RoadtripDayBoundary[];
|
||||
labels: { start: string; end: string };
|
||||
}): RoadtripRoutes {
|
||||
const chains = spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]);
|
||||
// A night spent at one hotel is a leg going nowhere, whatever was fetched for the pair:
|
||||
// the hotel in the evening and the same hotel the next morning are one spot.
|
||||
const storedLegFor = withStationaryJoins((from, to) => allLegs[legKey(from, to)]);
|
||||
const chains = spillChains(plan, quietDays, storedLegFor);
|
||||
|
||||
const allSnaps: Record<string, SnappedWaypoint> = {};
|
||||
for (const day of plan) Object.assign(allSnaps, snapByDay[day.dayId] ?? {});
|
||||
const storedLegFor = (from: RoadtripStop, to: RoadtripStop): RoutedLeg | undefined => allLegs[legKey(from, to)];
|
||||
const timed = window
|
||||
? planDayWindow(
|
||||
[...plan, ...quietDays],
|
||||
@@ -103,6 +106,8 @@ export function assembleRoadtrip({
|
||||
const lineJoins: boolean[] = [];
|
||||
const segments: RouteSegment[] = [];
|
||||
const accessLines: RoadtripRoutes['accessLines'] = [];
|
||||
const spurKeys = new Set<string>();
|
||||
const bookendSpots = new Set<string>();
|
||||
|
||||
const out: RoadtripDay[] = [];
|
||||
let carryKm: number | null = 0;
|
||||
@@ -122,7 +127,13 @@ export function assembleRoadtrip({
|
||||
inboundAt.set(spill.at, { seg: spill.leg, line: spill.line, drawnAs: spill.fromDayNumber });
|
||||
}
|
||||
|
||||
const joined = inboundAt.has(0) ? undefined : previousStop && legFor(previousStop, chain.stops[0]!);
|
||||
// No band for a night spent at one hotel: nothing is driven between the evening and
|
||||
// the morning, and a band would read "0 km" between a hotel and itself.
|
||||
const first = chain.stops[0];
|
||||
const joined =
|
||||
inboundAt.has(0) || !previousStop || !first || isStationaryJoin(previousStop, first)
|
||||
? undefined
|
||||
: legFor(previousStop, first);
|
||||
if (joined) {
|
||||
inboundAt.set(0, { seg: joined.seg, line: joined.line, drawnAs: previousDayNumber ?? chain.dayNumber });
|
||||
arrivingLeg = joined.seg;
|
||||
@@ -188,9 +199,17 @@ export function assembleRoadtrip({
|
||||
const schedule = chain.schedule;
|
||||
const legVias = routed.map((l) => l?.vias ?? []);
|
||||
const stops = chain.stops.map((s) => {
|
||||
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)];
|
||||
const key = stopKey(s);
|
||||
const snap = s.automaticNight ? undefined : allSnaps[key];
|
||||
const line = spurFor(snap);
|
||||
if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) });
|
||||
// A hotel the days start and end at is one walk from the road, however many of its
|
||||
// bookends and its own stop stand there: drawn once.
|
||||
const twice = spurKeys.has(key) && (!!s.bookend || bookendSpots.has(key));
|
||||
if (line && !twice) {
|
||||
accessLines.push({ line, meters: snap!.meters, stopKey: key });
|
||||
spurKeys.add(key);
|
||||
}
|
||||
if (s.bookend) bookendSpots.add(key);
|
||||
return { ...s, offRoadMeters: line ? snap!.meters : null };
|
||||
});
|
||||
|
||||
@@ -341,9 +360,17 @@ export function assembleRoadtrip({
|
||||
0,
|
||||
),
|
||||
|
||||
// A quiet day lists what could be moved onto it, and a bookend is nothing anybody moves:
|
||||
// it is left behind alone only when every stop after it went on past midnight.
|
||||
quietDays: out
|
||||
.filter((d) => !standsAsDay(d.stops) && !d.spills?.length && !d.stops.some((s) => s.automaticNight))
|
||||
.map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
.map((d) => ({
|
||||
dayId: d.dayId,
|
||||
dayNumber: d.dayNumber,
|
||||
date: d.date,
|
||||
title: d.title,
|
||||
stops: d.stops.filter((s) => !s.bookend),
|
||||
})),
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ROADTRIP-CARRIERS-001..029: a booking the traveller rides becomes a seam in the drive,
|
||||
* ROADTRIP-CARRIERS-001..031: 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
|
||||
@@ -10,6 +10,8 @@
|
||||
* 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). From 028 on: a ride
|
||||
* that lands on a later day is not seated there by the slot seeded on the day it left.
|
||||
* From 030 on: a booked night at a day's edge takes no via, and a terminal says which
|
||||
* edge of the day it holds.
|
||||
*/
|
||||
import { assembleRoadtrip } from './assemble';
|
||||
import {
|
||||
@@ -20,15 +22,18 @@ import {
|
||||
carrierReservationIds,
|
||||
carrierSeam,
|
||||
carriesTheCar,
|
||||
closesTheDay,
|
||||
isCarrierMode,
|
||||
isPickupStop,
|
||||
isUndatedRide,
|
||||
opensTheDay,
|
||||
rideSeatAfter,
|
||||
sameDayRide,
|
||||
seatCarrierStops,
|
||||
terminalAssignmentId,
|
||||
undatedRides,
|
||||
viasLeaving,
|
||||
viasOnLeg,
|
||||
type CarrierBooking,
|
||||
type SeatItem,
|
||||
} from './carriers';
|
||||
@@ -815,3 +820,51 @@ describe('a ride that lands on a later day (#2461)', () => {
|
||||
expect(car.arrival!.position).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booked night at the edge of a day', () => {
|
||||
const hotel = (phase: 'morning' | 'evening', ownerIndex: number) =>
|
||||
stop({
|
||||
ownerIndex,
|
||||
bookend: {
|
||||
phase,
|
||||
accommodationId: 5,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
},
|
||||
});
|
||||
const vias = [
|
||||
{ day_id: 1, after_order_index: 0, sequence: 0, lat: 1, lng: 1 },
|
||||
{ day_id: 1, after_order_index: 1, sequence: 0, lat: 2, lng: 2 },
|
||||
];
|
||||
|
||||
it('ROADTRIP-CARRIERS-030: no via leaves the hotel, and none bends the drive to tonight’s', () => {
|
||||
// The morning's hotel borrows the first stop's index; the points filed there are
|
||||
// that stop's, for the drive leaving it.
|
||||
expect(viasLeaving(hotel('morning', 0), vias)).toEqual([]);
|
||||
expect(viasOnLeg(hotel('morning', 0), stop({ ownerIndex: 0 }), vias)).toEqual([]);
|
||||
expect(viasOnLeg(stop({ ownerIndex: 0 }), stop({ ownerIndex: 1 }), vias)).toEqual([vias[0]]);
|
||||
// The points behind the day's last stop shape the road into tomorrow, which the drive
|
||||
// to tonight's hotel is not.
|
||||
expect(viasOnLeg(stop({ ownerIndex: 1 }), hotel('evening', 2), vias)).toEqual([]);
|
||||
expect(viasLeaving(stop({ ownerIndex: 1 }), vias)).toEqual([vias[1]]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-031: a landing or a pick-up opens a day, a departure or a hand-back closes it', () => {
|
||||
expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => opensTheDay(role as never))).toEqual([
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(['arrival', 'pickup', 'departure', 'return', undefined].map((role) => closesTheDay(role as never))).toEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,10 +475,15 @@ function terminalStop(
|
||||
}
|
||||
|
||||
/** Whether a terminal opens a day it has no timed stop before it on: an arrival or a pick-up. */
|
||||
function opensTheDay(role: CarrierTerminal['role']): boolean {
|
||||
export function opensTheDay(role: CarrierTerminal['role'] | undefined): boolean {
|
||||
return role === 'arrival' || role === 'pickup';
|
||||
}
|
||||
|
||||
/** Whether a terminal closes the day it stands last on: a departure or a hire car handed back. */
|
||||
export function closesTheDay(role: CarrierTerminal['role'] | undefined): boolean {
|
||||
return role === 'departure' || role === 'return';
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's stops with the terminals of its rides, and the desks of its hire cars, seated
|
||||
* among them.
|
||||
@@ -653,20 +658,38 @@ export function carrierLegsFor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The via points shaping the drive that leaves a stop. None leave a terminal: a via is
|
||||
* filed by the position of a stored stop, and a terminal stands in for none, so the
|
||||
* points filed at its index belong to the stop that really has that index.
|
||||
* The via points shaping the drive that leaves a stop. None leave a terminal or a booked
|
||||
* night at a day's edge: a via is filed by the position of a stored stop, and neither
|
||||
* stands in for one, so the points filed at its index belong to the stop that really has
|
||||
* that index.
|
||||
*/
|
||||
export function viasLeaving<V extends { day_id: number; after_order_index: number; sequence: number }>(
|
||||
stop: Pick<RoadtripStop, 'carrier' | 'ownerDayId' | 'ownerIndex'>,
|
||||
stop: Pick<RoadtripStop, 'carrier' | 'bookend' | 'ownerDayId' | 'ownerIndex'>,
|
||||
vias: readonly V[],
|
||||
): V[] {
|
||||
if (stop.carrier) return [];
|
||||
if (stop.carrier || stop.bookend) return [];
|
||||
return vias
|
||||
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via points shaping the drive from `from` to `to`.
|
||||
*
|
||||
* Those leaving `from`, unless the drive ends at a booked night's hotel. The points behind
|
||||
* a day's last stored stop shape the road from it into the next day (`carriedSeam`), and
|
||||
* with the night booked that road is no longer driven: the day ends at the hotel. Bent
|
||||
* through them, the drive to the hotel would run out along tomorrow's road and back. They
|
||||
* stay stored, and bend the road again once the day ends at its last stop.
|
||||
*/
|
||||
export function viasOnLeg<V extends { day_id: number; after_order_index: number; sequence: number }>(
|
||||
from: Pick<RoadtripStop, 'carrier' | 'bookend' | 'ownerDayId' | 'ownerIndex'>,
|
||||
to: Pick<RoadtripStop, 'bookend'>,
|
||||
vias: readonly V[],
|
||||
): V[] {
|
||||
return to.bookend ? [] : viasLeaving(from, vias);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings whose ride the days draw, for the map to show their arcs beside the roads.
|
||||
* A hire car is left out: its line on the map is the road the drive already draws.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MAX_TRIP_DAYS } from '../trip/trip.schema';
|
||||
import { dayWindow, planDayWindow, roadtripInsertion } from './dayWindow';
|
||||
import type { RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import { withStationaryJoins } from './nightBookends';
|
||||
import type { BookendPhase, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -529,3 +530,136 @@ describe('a time set to leave a stop, with daily travel times', () => {
|
||||
expect(night!.leaveAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('night bookends in the day window', () => {
|
||||
const HOTEL = { lat: 1, lng: 1 };
|
||||
const bookend = (id: number, dayId: number, phase: BookendPhase, ownerIndex: number, at = HOTEL) =>
|
||||
stop(id, {
|
||||
assignmentId: -6_000_000_000 - id,
|
||||
ownerDayId: dayId,
|
||||
ownerIndex,
|
||||
...at,
|
||||
stopType: 'hotel',
|
||||
name: `${phase} hotel`,
|
||||
bookend: {
|
||||
phase,
|
||||
accommodationId: 5,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
},
|
||||
});
|
||||
const minutesBetween = (table: Record<string, number>) =>
|
||||
withStationaryJoins((a, b) => leg(table[`${a.assignmentId}>${b.assignmentId}`] ?? 60, a, b));
|
||||
const names = (chain: { stops: RoadtripStop[] } | undefined) =>
|
||||
(chain?.stops ?? []).map((s) => s.automaticNight?.phase ?? s.name);
|
||||
|
||||
// Day 1 drives to the hotel it sleeps in, day 2 sets out from it.
|
||||
const p1 = stop(1, { time: '08:00' });
|
||||
const p2 = stop(2, { dwellMinutes: 60 });
|
||||
const evening = bookend(90, 1, 'evening', 2);
|
||||
const morning = bookend(91, 2, 'morning', 0);
|
||||
const p3 = stop(3, { ownerDayId: 2, ownerIndex: 0, lng: 7 });
|
||||
const p4 = stop(4, { ownerDayId: 2, ownerIndex: 1, lng: 8 });
|
||||
// Five hours to the second place, five more to the hotel: in at 19:00, past the window.
|
||||
const long = minutesBetween({ '1>2': 300, [`2>${evening.assignmentId}`]: 300 });
|
||||
const twoDays = (first: RoadtripStop[], second: RoadtripStop[] = [morning, p3, p4]) => [
|
||||
day(1, first),
|
||||
day(2, second),
|
||||
];
|
||||
|
||||
it('never cuts the drive to tonight’s hotel, and closes the day there', () => {
|
||||
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
|
||||
expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['08:00', '13:00', '19:00', '19:00']);
|
||||
expect(names(plan.chains[1])).toEqual(['start', 'morning hotel', 'Stop 3', 'Stop 4']);
|
||||
expect(plan.chains[1]!.schedule.entries.slice(0, 2).map((e) => e.arrival)).toEqual(['08:00', '08:00']);
|
||||
});
|
||||
|
||||
it('never puts the drive to tonight’s hotel off until the morning either', () => {
|
||||
const plan = planDayWindow(twoDays([p1, p2, evening]), { ...hours, endMode: 'stop' }, long, 'metric', labels);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
|
||||
});
|
||||
|
||||
it('leaves the marker at the hotel without the hotel’s booking on it', () => {
|
||||
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels);
|
||||
const markers = plan.chains.flatMap((c) => c.stops.filter((s) => s.automaticNight));
|
||||
expect(markers).toHaveLength(2);
|
||||
for (const marker of markers) {
|
||||
expect(marker.bookend).toBeUndefined();
|
||||
expect(marker).toMatchObject(HOTEL);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends a day ended at its last place behind the hotel, by hand or by the stop', () => {
|
||||
const short = minutesBetween({});
|
||||
const byStop = planDayWindow(twoDays([p1, { ...p2, endDay: true }, evening]), hours, short, 'metric', labels);
|
||||
expect(byStop.issue).toBeNull();
|
||||
expect(names(byStop.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
|
||||
const byHand = planDayWindow(twoDays([p1, p2, evening]), hours, short, 'metric', labels, [
|
||||
{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 },
|
||||
]);
|
||||
expect(byHand.issue).toBeNull();
|
||||
expect(names(byHand.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
|
||||
expect(byHand.chains[0]!.stops[3]!.automaticNight).toMatchObject({ manual: true });
|
||||
});
|
||||
|
||||
it('lets a boundary across the night go: the booked night ends the day there already', () => {
|
||||
const plan = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [
|
||||
{ day_number: 1, from_assignment_id: 2, to_assignment_id: 3, fraction: 0.5 },
|
||||
]);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(names(plan.chains[0])).toEqual(['Stop 1', 'Stop 2', 'evening hotel', 'end']);
|
||||
// Across a stop it is still the conflict it always was.
|
||||
const across = planDayWindow(twoDays([p1, p2, evening]), hours, long, 'metric', labels, [
|
||||
{ day_number: 1, from_assignment_id: 1, to_assignment_id: 3, fraction: 0.5 },
|
||||
]);
|
||||
expect(across.issue).toBe('conflict');
|
||||
});
|
||||
|
||||
it('sets out from the hotel early for a place pinned before the window opens', () => {
|
||||
const pinned = { ...p3, time: '07:00' };
|
||||
const plan = planDayWindow(
|
||||
twoDays([p1, p2, evening], [morning, pinned, p4]),
|
||||
hours,
|
||||
minutesBetween({}),
|
||||
'metric',
|
||||
labels,
|
||||
);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(plan.chains[1]!.schedule.entries.slice(0, 3).map((e) => e.arrival)).toEqual(['06:00', '06:00', '07:00']);
|
||||
});
|
||||
|
||||
it('does so on the first day of the drive too, where no marker comes before the hotel', () => {
|
||||
const pinned = { ...p3, time: '07:00' };
|
||||
const plan = planDayWindow([day(2, [morning, pinned, p4])], hours, minutesBetween({}), 'metric', labels);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(plan.chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['06:00', '07:00', '08:00']);
|
||||
});
|
||||
|
||||
it('keeps the drive between two hotels when the morning moves early', () => {
|
||||
const other = bookend(92, 2, 'morning', 0, { lat: 1, lng: 3 });
|
||||
const pinned = { ...p3, time: '07:00' };
|
||||
const lookup = minutesBetween({ [`${evening.assignmentId}>${other.assignmentId}`]: 30 });
|
||||
const plan = planDayWindow(twoDays([p1, p2, evening], [other, pinned]), hours, lookup, 'metric', labels);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(plan.chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['05:30', '06:00', '07:00']);
|
||||
});
|
||||
|
||||
it('files a stop added behind tonight’s hotel or a desk behind the day’s last place', () => {
|
||||
const card = { stops: [morning, p3, p4, bookend(93, 2, 'evening', 2)] };
|
||||
expect(roadtripInsertion(card, 0)).toEqual({ dayId: 2, position: 0 });
|
||||
expect(roadtripInsertion(card, 1)).toEqual({ dayId: 2, position: 0 });
|
||||
expect(roadtripInsertion(card, 3)).toEqual({ dayId: 2, position: 2 });
|
||||
expect(roadtripInsertion(card, 4)).toEqual({ dayId: 2, position: 2 });
|
||||
const handedBack = stop(9, {
|
||||
ownerIndex: 2,
|
||||
carrier: { reservationId: 4, type: 'car', role: 'return', title: 'Car', code: null, at: null },
|
||||
});
|
||||
expect(roadtripInsertion({ stops: [p1, p2, handedBack] }, 3)).toEqual({ dayId: 1, position: 2 });
|
||||
expect(roadtripInsertion({ stops: [p1, p2] }, 2)).toEqual({ dayId: 1, position: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema';
|
||||
import type { SpillChain } from './nightSpill';
|
||||
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import type { DistanceUnit } from './planning-types';
|
||||
import { formatClock, hasChosenArrival, leaveAfter, parseClock } from './roadtripModel';
|
||||
import { formatClock, hasChosenArrival, isStoredStop, leaveAfter, parseClock } from './roadtripModel';
|
||||
import { formatDurationShort } from './roadtripModel';
|
||||
import { formatDistance } from './units';
|
||||
|
||||
@@ -70,7 +70,13 @@ function portion(leg: RoutedLeg, from: number, to: number, unit: DistanceUnit):
|
||||
};
|
||||
}
|
||||
|
||||
function stationary(stop: RoadtripStop): RoutedLeg {
|
||||
/**
|
||||
* The leg of a drive that goes nowhere: from a stop to the same spot, nothing measured.
|
||||
*
|
||||
* Between the last stop of a day and the marker that closes it, and between a booked
|
||||
* night's hotel in the evening and the same hotel the next morning (`withStationaryJoins`).
|
||||
*/
|
||||
export function stationary(stop: RoadtripStop): RoutedLeg {
|
||||
const at: [number, number] = [stop.lat, stop.lng];
|
||||
return {
|
||||
line: [],
|
||||
@@ -108,13 +114,36 @@ export function planDayWindow(
|
||||
const failed = (issue: WindowPlan['issue']): WindowPlan => ({ chains: [], legFor: lookup, issue });
|
||||
if (!stops.length) return { chains: [], legFor: lookup, issue: null };
|
||||
const positions = new Map(stops.map(({ stop }, i) => [stop.assignmentId, i]));
|
||||
// Where a day ended at stop `i` really ends: behind tonight's hotel when the booked night
|
||||
// stands right after it on the same day. Ended at the stop, the hotel would be put on the
|
||||
// morning after, a drive to bed made the next day.
|
||||
const behindTonight = (i: number): number => {
|
||||
const next = stops[i + 1]?.stop;
|
||||
return next?.bookend?.phase === 'evening' && next.ownerDayId === stops[i]!.stop.ownerDayId ? i + 1 : i;
|
||||
};
|
||||
// A stop set to end its day ends it there, or behind tonight's hotel right after it.
|
||||
const endsDayHere = (i: number): boolean => {
|
||||
if (stops[i]!.stop.endDay) return behindTonight(i) === i;
|
||||
return i > 0 && !!stops[i - 1]!.stop.endDay && behindTonight(i - 1) === i;
|
||||
};
|
||||
const targets = new Map<number, number>();
|
||||
let lastTarget = -1;
|
||||
for (const boundary of [...boundaries].sort((a, b) => a.day_number - b.day_number)) {
|
||||
const from = positions.get(boundary.from_assignment_id);
|
||||
const to = boundary.to_assignment_id === null ? null : positions.get(boundary.to_assignment_id);
|
||||
// A boundary between two stops a booked night now stands between is left alone: the
|
||||
// night ends the day there already, and the two are no longer next to each other only
|
||||
// because the hotel stands in between.
|
||||
if (
|
||||
from !== undefined &&
|
||||
to !== null &&
|
||||
to !== undefined &&
|
||||
to > from + 1 &&
|
||||
stops.slice(from + 1, to).every(({ stop }) => stop.bookend)
|
||||
)
|
||||
continue;
|
||||
if (from === undefined || (to !== null && to !== from + 1)) return failed('conflict');
|
||||
const target = from + (to === null ? 0 : boundary.fraction);
|
||||
const target = to === null ? behindTonight(from) : from + boundary.fraction;
|
||||
if (target <= lastTarget) return failed('conflict');
|
||||
targets.set(boundary.day_number, target);
|
||||
lastTarget = target;
|
||||
@@ -178,8 +207,10 @@ export function planDayWindow(
|
||||
const last = chain.stops[chain.stops.length - 1]!;
|
||||
const end: RoadtripStop = {
|
||||
...at,
|
||||
// A marker placed where a terminal stands is a marker, not the terminal.
|
||||
// A marker placed where a terminal or a booked night's hotel stands is a marker, not
|
||||
// the terminal or the hotel.
|
||||
carrier: undefined,
|
||||
bookend: undefined,
|
||||
assignmentId: -2000000000 - number * 2,
|
||||
name: labels.end,
|
||||
time: null,
|
||||
@@ -271,17 +302,34 @@ export function planDayWindow(
|
||||
let early: number | null = null;
|
||||
if (pin !== null) early = pin - minutes;
|
||||
else if (leave !== null && leave - minutes >= 0) early = leave - minutes;
|
||||
if (early !== null && early < clock && chainAt(number).stops.every((s) => s.automaticNight)) {
|
||||
// The hotel the day wakes up in is part of the morning, not a stop in the way of it:
|
||||
// the day sets out from there early just the same. Everything before this leg moves
|
||||
// back by the same minutes, so the marker keeps the drive to the hotel it has, which
|
||||
// is none when the night was spent there.
|
||||
const morning = chainAt(number);
|
||||
if (
|
||||
early !== null &&
|
||||
early < clock &&
|
||||
morning.stops.every((s) => s.automaticNight || s.bookend?.phase === 'morning')
|
||||
) {
|
||||
const shift = early - clock;
|
||||
const moved = (at: string | null): number => (parseClock(at) ?? clock) + shift;
|
||||
if (early < 0 || moved(morning.schedule.entries[0]!.arrival) < 0) return failed('conflict');
|
||||
for (const entry of morning.schedule.entries) {
|
||||
entry.arrival = formatClock(Math.round(moved(entry.arrival)));
|
||||
entry.departure = formatClock(Math.round(moved(entry.departure)));
|
||||
}
|
||||
clock = early;
|
||||
if (clock < 0) return failed('conflict');
|
||||
const startEntry = chainAt(number).schedule.entries[0]!;
|
||||
startEntry.arrival = formatClock(Math.round(clock));
|
||||
startEntry.departure = formatClock(Math.round(clock));
|
||||
}
|
||||
if (pin !== null && clock + minutes > pin + 1) return failed('conflict');
|
||||
|
||||
let fraction = 0;
|
||||
const split = pin === null && (leg.seg.mode === 'driving' || leg.seg.mode === undefined);
|
||||
// The drive to tonight's hotel is never cut by the window, and never put off to the
|
||||
// morning: the night is booked there, so the day ends there, later than the window
|
||||
// says if it must. Cut, the day ended on the road and the hotel was reached the next
|
||||
// morning, just before the day set out from it again.
|
||||
const split =
|
||||
pin === null && stop.bookend?.phase !== 'evening' && (leg.seg.mode === 'driving' || leg.seg.mode === undefined);
|
||||
if (split && !targets.has(number) && window.endMode === 'stop' && clock + minutes > window.end + 0.000001) {
|
||||
if (minutes > window.end - window.start) return failed('legTooLong');
|
||||
previous = night(previous);
|
||||
@@ -360,7 +408,7 @@ export function planDayWindow(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target === position || (!targets.has(number) && stop.endDay && i < stops.length - 1)) {
|
||||
if (target === position || (!targets.has(number) && endsDayHere(i) && i < stops.length - 1)) {
|
||||
previous = night(previous, i === stops.length - 1);
|
||||
if (issue) return failed(issue);
|
||||
}
|
||||
@@ -398,9 +446,17 @@ export function planDayWindow(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a stop inserted at `index` of a card lands on the day it is stored on.
|
||||
*
|
||||
* In front of the stop at that index, which is its own index there. Behind the card's last
|
||||
* stop, one further on, unless that stop is a terminal or tonight's hotel: those stand
|
||||
* behind the stored stops with the index the next stored stop would have, which is the
|
||||
* position a stop added at the end of the day takes.
|
||||
*/
|
||||
export function roadtripInsertion(day: Pick<RoadtripDay, 'stops'>, index: number) {
|
||||
const stop = day.stops[Math.min(Math.max(0, index), day.stops.length - 1)]!;
|
||||
return stop
|
||||
? { dayId: stop.ownerDayId, position: stop.ownerIndex + (stop.automaticNight || index >= day.stops.length ? 1 : 0) }
|
||||
: null;
|
||||
if (!stop) return null;
|
||||
const behind = stop.automaticNight || (index >= day.stops.length && isStoredStop(stop));
|
||||
return { dayId: stop.ownerDayId, position: stop.ownerIndex + (behind ? 1 : 0) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* ROADTRIP-BOOKENDS-001..027: a booked night stands at both ends of the days around it.
|
||||
*
|
||||
* Pinned here: which hotel a day wakes up in and which it sleeps in, where the two are
|
||||
* seated and where they are not (the hotel already there, a landing, a departure, a hire
|
||||
* car's desk, a day with nothing of its own), what a bookend carries and what it never
|
||||
* does (a clock), that the choice does not depend on the order the stays come in, and
|
||||
* that a night spent at one hotel is a leg going nowhere.
|
||||
*/
|
||||
import { terminalAssignmentId } from './carriers';
|
||||
import {
|
||||
bookendAssignmentId,
|
||||
bookendStaysOf,
|
||||
hotelBookendsOn,
|
||||
isStationaryJoin,
|
||||
seatNightBookends,
|
||||
withStationaryJoins,
|
||||
type BookendStay,
|
||||
} from './nightBookends';
|
||||
import type { CarrierTerminal, PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import { standsAsDay } from './roadtripModel';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const days = [1, 2, 3, 4, 5, 6].map((n) => ({ id: n * 10, day_number: n }));
|
||||
const [D1, D2, D3, D4] = [10, 20, 30, 40];
|
||||
|
||||
const GETAWAY: [number, number] = [-33.5, 150.4];
|
||||
const WALLINGA: [number, number] = [-34.1, 150.9];
|
||||
const HOTEL: [number, number] = [45.1, 7.6];
|
||||
|
||||
const stay = (
|
||||
id: number,
|
||||
start: number,
|
||||
end: number,
|
||||
at: [number, number] | null,
|
||||
over: Partial<BookendStay> = {},
|
||||
): BookendStay => ({
|
||||
id,
|
||||
place_id: 500 + id,
|
||||
start_day_id: start,
|
||||
end_day_id: end,
|
||||
place_lat: at ? at[0] : null,
|
||||
place_lng: at ? at[1] : null,
|
||||
place_name: `Stay ${id}`,
|
||||
check_out: '10:00',
|
||||
reservation_id: 900 + id,
|
||||
...over,
|
||||
});
|
||||
|
||||
let nextId = 1;
|
||||
const visit = (dayId: number, ownerIndex: number, over: Partial<RoadtripStop> = {}): RoadtripStop => {
|
||||
const id = nextId++;
|
||||
return {
|
||||
assignmentId: id,
|
||||
ownerDayId: dayId,
|
||||
ownerIndex,
|
||||
placeId: 100 + id,
|
||||
name: `Place ${id}`,
|
||||
lat: 40 + id * 0.01,
|
||||
lng: 5 + id * 0.01,
|
||||
time: null,
|
||||
dwellMinutes: 30,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
};
|
||||
};
|
||||
/** The stay's own stop on its check-in day, where the booking put it (#2354). */
|
||||
const stayStop = (dayId: number, ownerIndex: number, at: [number, number], over: Partial<RoadtripStop> = {}) =>
|
||||
visit(dayId, ownerIndex, { lat: at[0], lng: at[1], night: true, stopType: 'hotel', ...over });
|
||||
const terminal = (dayId: number, ownerIndex: number, role: CarrierTerminal['role']): RoadtripStop =>
|
||||
visit(dayId, ownerIndex, {
|
||||
carrier: {
|
||||
reservationId: 77,
|
||||
type: role === 'pickup' || role === 'return' ? 'car' : 'flight',
|
||||
role,
|
||||
title: 'Ride',
|
||||
code: null,
|
||||
at: null,
|
||||
},
|
||||
assignmentId: terminalAssignmentId(77, role),
|
||||
placeId: -77,
|
||||
});
|
||||
|
||||
const planDay = (dayId: number, stops: RoadtripStop[]): PlanDay => ({
|
||||
dayId,
|
||||
dayNumber: dayId / 10,
|
||||
date: null,
|
||||
title: null,
|
||||
stops,
|
||||
});
|
||||
const shape = (day: PlanDay | undefined): string[] =>
|
||||
(day?.stops ?? []).map((s) =>
|
||||
s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.carrier ? s.carrier.role : s.name,
|
||||
);
|
||||
|
||||
describe('a check-in with places, then a transfer day with nothing of its own', () => {
|
||||
const getaway = stay(1, D1, D2, GETAWAY, { check_out: '10:00' });
|
||||
const wallinga = stay(2, D2, D3, WALLINGA);
|
||||
const checkIn = stayStop(D1, 0, GETAWAY, { name: 'Getaway', checkInTime: '14:00' });
|
||||
const lookout = visit(D1, 1, { name: 'Lookout' });
|
||||
const falls = visit(D1, 2, { name: 'Falls' });
|
||||
const wallingaStop = stayStop(D2, 0, WALLINGA, { name: 'Wallinga', checkInTime: '15:00' });
|
||||
const plan = [planDay(D1, [checkIn, lookout, falls]), planDay(D2, [wallingaStop]), planDay(D3, [])];
|
||||
const seated = seatNightBookends(plan, days, [getaway, wallinga]);
|
||||
|
||||
it('ROADTRIP-BOOKENDS-001: the check-in day ends back at the stay, and nothing wakes up there', () => {
|
||||
expect(shape(seated[0])).toEqual(['Getaway', 'Lookout', 'Falls', 'evening:1']);
|
||||
const back = seated[0]!.stops[3]!;
|
||||
expect(back.bookend).toEqual({
|
||||
phase: 'evening',
|
||||
accommodationId: 1,
|
||||
reservationId: 901,
|
||||
checkingOut: false,
|
||||
checkingIn: true,
|
||||
checkOut: null,
|
||||
});
|
||||
expect(back).toMatchObject({
|
||||
placeId: 501,
|
||||
name: 'Stay 1',
|
||||
lat: GETAWAY[0],
|
||||
lng: GETAWAY[1],
|
||||
ownerDayId: D1,
|
||||
ownerIndex: 3,
|
||||
stopType: 'hotel',
|
||||
assignmentId: bookendAssignmentId(D1, 'evening'),
|
||||
});
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-002: the transfer day checks out of one stay and drives to the stop of the next', () => {
|
||||
expect(shape(seated[1])).toEqual(['morning:1', 'Wallinga']);
|
||||
const out = seated[1]!.stops[0]!;
|
||||
expect(out.bookend).toMatchObject({ phase: 'morning', checkingOut: true, checkingIn: false, checkOut: '10:00' });
|
||||
expect(out.ownerIndex).toBe(0);
|
||||
// Tonight's stay is already the day's last stop, so no evening is seated behind it.
|
||||
expect(seated[1]!.stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-003: a day that is only a check-out gets no drive, and keeps its object', () => {
|
||||
expect(seated[2]!.stops).toEqual([]);
|
||||
expect(seated[2]).toBe(plan[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('three nights in one hotel, places on the days between', () => {
|
||||
const hotel = stay(5, D1, D4, HOTEL);
|
||||
const h = stayStop(D1, 0, HOTEL, { name: 'H', checkInTime: '15:00' });
|
||||
const plan = [
|
||||
planDay(D1, [h, visit(D1, 1, { name: 'P1' }), visit(D1, 2, { name: 'P2' })]),
|
||||
planDay(D2, [visit(D2, 0, { name: 'P3' }), visit(D2, 1, { name: 'P4' })]),
|
||||
planDay(D3, [visit(D3, 0, { name: 'P5' }), visit(D3, 1, { name: 'P6' })]),
|
||||
planDay(D4, []),
|
||||
];
|
||||
const seated = seatNightBookends(plan, days, [hotel]);
|
||||
|
||||
it('ROADTRIP-BOOKENDS-004: every day between the nights starts and ends at the hotel', () => {
|
||||
expect(shape(seated[0])).toEqual(['H', 'P1', 'P2', 'evening:5']);
|
||||
expect(shape(seated[1])).toEqual(['morning:5', 'P3', 'P4', 'evening:5']);
|
||||
expect(shape(seated[2])).toEqual(['morning:5', 'P5', 'P6', 'evening:5']);
|
||||
expect(seated[1]!.stops[0]!.bookend).toMatchObject({ checkingOut: false, checkingIn: false, checkOut: null });
|
||||
// The evening keeps the index the next stored stop would have, the terminals' rule.
|
||||
expect(seated[1]!.stops[3]!.ownerIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-005: the empty check-out day is not driven', () => {
|
||||
expect(seated[3]).toBe(plan[3]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-006: a day in the hotel with nothing planned stays quiet, one with a single place becomes a loop', () => {
|
||||
const quiet = seatNightBookends([planDay(D2, [])], days, [hotel]);
|
||||
expect(quiet[0]!.stops).toEqual([]);
|
||||
const one = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [hotel]);
|
||||
expect(shape(one[0])).toEqual(['morning:5', 'P', 'evening:5']);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-007: the hotel already first or last on the day is not seated again on that side', () => {
|
||||
const first = seatNightBookends(
|
||||
[planDay(D2, [stayStop(D2, 0, HOTEL, { name: 'H again' }), visit(D2, 1, { name: 'P' })])],
|
||||
days,
|
||||
[hotel],
|
||||
);
|
||||
expect(shape(first[0])).toEqual(['H again', 'P', 'evening:5']);
|
||||
const last = seatNightBookends(
|
||||
[planDay(D2, [visit(D2, 0, { name: 'P' }), stayStop(D2, 1, HOTEL, { name: 'H again' })])],
|
||||
days,
|
||||
[hotel],
|
||||
);
|
||||
expect(shape(last[0])).toEqual(['morning:5', 'P', 'H again']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a transfer day', () => {
|
||||
const a = stay(1, D1, D2, GETAWAY);
|
||||
const b = stay(2, D2, D3, WALLINGA);
|
||||
|
||||
it('ROADTRIP-BOOKENDS-008: with nothing stored, the day drives from one stay to the other', () => {
|
||||
const [day] = seatNightBookends([planDay(D2, [])], days, [a, b]);
|
||||
expect(shape(day)).toEqual(['morning:1', 'evening:2']);
|
||||
expect(day!.stops[0]!.bookend).toMatchObject({ checkingOut: true });
|
||||
expect(day!.stops[1]!.bookend).toMatchObject({ checkingIn: true, checkOut: null });
|
||||
expect(day!.stops.map((s) => s.ownerIndex)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-009: with places, a place with its own earlier hour stays where it is', () => {
|
||||
const p0 = visit(D2, 0, { name: 'P0', time: '11:00' });
|
||||
const checkIn = stayStop(D2, 1, WALLINGA, { name: 'B', checkInTime: '15:00' });
|
||||
const p1 = visit(D2, 2, { name: 'P1' });
|
||||
const [day] = seatNightBookends([planDay(D2, [p0, checkIn, p1])], days, [a, b]);
|
||||
expect(shape(day)).toEqual(['morning:1', 'P0', 'B', 'P1', 'evening:2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a day that starts or ends on a ride or at a hire car desk', () => {
|
||||
const hotel = stay(5, D1, D4, HOTEL);
|
||||
|
||||
// Landing, or at the desk the car is picked up at: the traveller was not at the hotel.
|
||||
it.each([{ role: 'arrival' }, { role: 'pickup' }] as const)(
|
||||
'ROADTRIP-BOOKENDS-010: no morning when $role opens the day',
|
||||
({ role }) => {
|
||||
const [day] = seatNightBookends([planDay(D2, [terminal(D2, 0, role), visit(D2, 0, { name: 'P' })])], days, [
|
||||
hotel,
|
||||
]);
|
||||
expect(shape(day)).toEqual([role, 'P', 'evening:5']);
|
||||
},
|
||||
);
|
||||
|
||||
// At the gate, or where the car goes back: the traveller is not driving to the hotel.
|
||||
it.each([{ role: 'departure' }, { role: 'return' }] as const)(
|
||||
'ROADTRIP-BOOKENDS-011: no evening when $role closes the day',
|
||||
({ role }) => {
|
||||
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), terminal(D2, 1, role)])], days, [
|
||||
hotel,
|
||||
]);
|
||||
expect(shape(day)).toEqual(['morning:5', 'P', role]);
|
||||
},
|
||||
);
|
||||
|
||||
it('ROADTRIP-BOOKENDS-012: a flight in the morning after a night sets off from the hotel', () => {
|
||||
const [day] = seatNightBookends(
|
||||
[planDay(D2, [terminal(D2, 0, 'departure'), terminal(D2, 0, 'arrival'), visit(D2, 0, { name: 'P' })])],
|
||||
days,
|
||||
[hotel],
|
||||
);
|
||||
expect(shape(day)).toEqual(['morning:5', 'departure', 'arrival', 'P', 'evening:5']);
|
||||
// Counted by stored stops, not by terminals.
|
||||
expect(day!.stops[4]!.ownerIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a stay without the facts gets', () => {
|
||||
it('ROADTRIP-BOOKENDS-013: a stay without coordinates gets no bookend, and the other side stands on its own', () => {
|
||||
const a = stay(1, D1, D2, null);
|
||||
const b = stay(2, D2, D3, WALLINGA);
|
||||
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [a, b]);
|
||||
expect(shape(day)).toEqual(['P', 'evening:2']);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-014: with no stay on the map at all, the plan comes back as it was', () => {
|
||||
const plan = [planDay(D2, [visit(D2, 0), visit(D2, 1)])];
|
||||
expect(seatNightBookends(plan, days, [stay(1, D1, D3, null), stay(2, D1, D3, null)])).toBe(plan);
|
||||
expect(seatNightBookends(plan, days, [stay(3, D1, D3, HOTEL, { place_id: null })])).toBe(plan);
|
||||
expect(seatNightBookends(plan, days, [])).toBe(plan);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-015: a stay entered without a booking still stands there, with nothing to open', () => {
|
||||
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' })])], days, [
|
||||
stay(5, D1, D3, HOTEL, { reservation_id: null }),
|
||||
]);
|
||||
expect(day!.stops[0]!.bookend?.reservationId).toBeNull();
|
||||
expect(day!.stops[2]!.bookend?.reservationId).toBeNull();
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-016: a stay that begins and ends on one day is slept in by nobody', () => {
|
||||
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0, { name: 'P' }), visit(D2, 1, { name: 'Q' })])], days, [
|
||||
stay(5, D2, D2, HOTEL),
|
||||
]);
|
||||
expect(shape(day)).toEqual(['P', 'Q']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('two stays on one day', () => {
|
||||
it('ROADTRIP-BOOKENDS-017: of two checked into on one day, the evening goes to the one entered first', () => {
|
||||
const later = stay(7, D2, D3, WALLINGA);
|
||||
const earlier = stay(3, D2, D3, HOTEL);
|
||||
const [day] = seatNightBookends([planDay(D2, [visit(D2, 0), visit(D2, 1)])], days, [later, earlier]);
|
||||
expect(day!.stops[2]!.bookend?.accommodationId).toBe(3);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-018: overlapping stays follow the day plan: woken up in the older, tonight in the one checked into', () => {
|
||||
const long = stay(1, D1, D4, HOTEL);
|
||||
const short = stay(2, D2, D3, WALLINGA);
|
||||
const plan = [planDay(D2, [visit(D2, 0, { name: 'P' })]), planDay(D3, [visit(D3, 0, { name: 'Q' })])];
|
||||
const seated = seatNightBookends(plan, days, [long, short]);
|
||||
expect(shape(seated[0])).toEqual(['morning:1', 'P', 'evening:2']);
|
||||
expect(shape(seated[1])).toEqual(['morning:1', 'Q', 'evening:1']);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-019: the order the stays come in changes nothing', () => {
|
||||
const stays = [stay(1, D1, D4, HOTEL), stay(2, D2, D3, WALLINGA), stay(3, D2, D3, GETAWAY)];
|
||||
const plan = [planDay(D2, [visit(D2, 0)]), planDay(D3, [visit(D3, 0)])];
|
||||
expect(seatNightBookends(plan, days, [...stays].reverse())).toEqual(seatNightBookends(plan, days, stays));
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a bookend is', () => {
|
||||
const hotel = stay(5, D1, D4, HOTEL);
|
||||
const plan = [2, 3].map((n) => planDay(n * 10, [visit(n * 10, 0), visit(n * 10, 1)]));
|
||||
const seated = seatNightBookends(plan, days, [hotel]);
|
||||
const bookends = seated.flatMap((d) => d.stops.filter((s) => s.bookend));
|
||||
|
||||
it('ROADTRIP-BOOKENDS-020: one id per day and phase, the same every run, and below every terminal', () => {
|
||||
const ids = bookends.map((s) => s.assignmentId);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids).toEqual([
|
||||
bookendAssignmentId(D2, 'morning'),
|
||||
bookendAssignmentId(D2, 'evening'),
|
||||
bookendAssignmentId(D3, 'morning'),
|
||||
bookendAssignmentId(D3, 'evening'),
|
||||
]);
|
||||
expect(seatNightBookends(plan, days, [hotel]).flatMap((d) => d.stops.map((s) => s.assignmentId))).toEqual(
|
||||
seated.flatMap((d) => d.stops.map((s) => s.assignmentId)),
|
||||
);
|
||||
for (const id of ids) expect(id).toBeLessThan(terminalAssignmentId(1_000_000, 'arrival'));
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-021: never a clock, a stay, a mode or a night of its own, whatever the stay says', () => {
|
||||
const [day] = seatNightBookends([planDay(D4, [visit(D4, 0), visit(D4, 1)])], days, [
|
||||
stay(5, D1, D4, HOTEL, { check_out: '11:00' }),
|
||||
]);
|
||||
const all = [...bookends, day!.stops[0]!];
|
||||
expect(day!.stops[0]!.bookend?.checkOut).toBe('11:00');
|
||||
for (const s of all) {
|
||||
expect(s).toMatchObject({
|
||||
time: null,
|
||||
leaveAt: null,
|
||||
checkInTime: null,
|
||||
dwellMinutes: 0,
|
||||
night: false,
|
||||
endDay: false,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
fillPercent: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-022: a day with a bookend has two stops or more, and stands as a day for it', () => {
|
||||
const everyCase = [
|
||||
...seated,
|
||||
...seatNightBookends([planDay(D2, [])], days, [stay(1, D1, D2, GETAWAY), stay(2, D2, D3, WALLINGA)]),
|
||||
...seatNightBookends([planDay(D2, [visit(D2, 0)])], days, [hotel]),
|
||||
...seatNightBookends([planDay(D4, [visit(D4, 0)])], days, [hotel]),
|
||||
];
|
||||
for (const day of everyCase.filter((d) => d.stops.some((s) => s.bookend))) {
|
||||
expect(day.stops.length).toBeGreaterThanOrEqual(2);
|
||||
expect(standsAsDay(day.stops)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-023: a day the trip does not list is passed over', () => {
|
||||
const stray = planDay(990, [visit(990, 0), visit(990, 1)]);
|
||||
const out = seatNightBookends([stray], days, [hotel]);
|
||||
expect(out[0]).toBe(stray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a night spent at one hotel', () => {
|
||||
const at = (over: Partial<RoadtripStop>) => visit(D2, 0, { lat: HOTEL[0], lng: HOTEL[1], ...over });
|
||||
const evening = at({
|
||||
bookend: {
|
||||
phase: 'evening',
|
||||
accommodationId: 5,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
},
|
||||
});
|
||||
const morning = at({
|
||||
bookend: {
|
||||
phase: 'morning',
|
||||
accommodationId: 5,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
},
|
||||
});
|
||||
const drive: RoutedLeg = {
|
||||
seg: {
|
||||
from: HOTEL,
|
||||
to: HOTEL,
|
||||
mid: HOTEL,
|
||||
distance: 172_000,
|
||||
duration: 7200,
|
||||
mode: 'driving',
|
||||
walkingText: '',
|
||||
drivingText: '',
|
||||
distanceText: '',
|
||||
},
|
||||
line: [HOTEL, [45.2, 7.7], HOTEL],
|
||||
vias: [],
|
||||
};
|
||||
|
||||
it('ROADTRIP-BOOKENDS-024: is a join only where a bookend stands at one end and both are one spot', () => {
|
||||
expect(isStationaryJoin(evening, morning)).toBe(true);
|
||||
expect(isStationaryJoin(at({}), morning)).toBe(true);
|
||||
expect(isStationaryJoin(evening, at({}))).toBe(true);
|
||||
// Two stored stops on one spot are a stop visited twice, not a night.
|
||||
expect(isStationaryJoin(at({}), at({}))).toBe(false);
|
||||
expect(isStationaryJoin(evening, { ...morning, lat: WALLINGA[0], lng: WALLINGA[1] })).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-BOOKENDS-025: goes nowhere whatever was fetched for the pair, and every other pair is looked up', () => {
|
||||
const lookup = withStationaryJoins(() => drive);
|
||||
const night = lookup(evening, morning)!;
|
||||
expect(night.seg.distance).toBe(0);
|
||||
expect(night.seg.duration).toBe(0);
|
||||
expect(night.line).toEqual([]);
|
||||
expect(lookup(at({}), at({}))).toBe(drive);
|
||||
expect(withStationaryJoins(() => undefined)(at({}), visit(D2, 1))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stays the rule reads, and the switch', () => {
|
||||
it('ROADTRIP-BOOKENDS-026: each stay once, with its earliest booking; the switch is on only when set on', () => {
|
||||
const accommodations = [
|
||||
{
|
||||
id: 1,
|
||||
place_id: 11,
|
||||
start_day_id: D1,
|
||||
end_day_id: D2,
|
||||
place_lat: 1,
|
||||
place_lng: 2,
|
||||
place_name: 'A',
|
||||
check_out: '10:00',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
place_id: 11,
|
||||
start_day_id: D1,
|
||||
end_day_id: D2,
|
||||
place_lat: 1,
|
||||
place_lng: 2,
|
||||
place_name: 'A',
|
||||
check_out: '10:00',
|
||||
},
|
||||
{ id: 2, place_id: 12, start_day_id: D2, end_day_id: D3 },
|
||||
];
|
||||
const reservations = [
|
||||
{ id: 40, accommodation_id: '1' },
|
||||
{ id: 31, accommodation_id: 1 },
|
||||
{ id: 12, accommodation_id: null },
|
||||
{ id: 5, accommodation_id: '' },
|
||||
];
|
||||
expect(bookendStaysOf(accommodations, reservations)).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
place_id: 11,
|
||||
start_day_id: D1,
|
||||
end_day_id: D2,
|
||||
place_lat: 1,
|
||||
place_lng: 2,
|
||||
place_name: 'A',
|
||||
check_out: '10:00',
|
||||
reservation_id: 31,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
place_id: 12,
|
||||
start_day_id: D2,
|
||||
end_day_id: D3,
|
||||
place_lat: null,
|
||||
place_lng: null,
|
||||
place_name: null,
|
||||
check_out: null,
|
||||
reservation_id: null,
|
||||
},
|
||||
]);
|
||||
expect(hotelBookendsOn({ roadtrip_hotel_bookends: true })).toBe(true);
|
||||
expect(hotelBookendsOn({ roadtrip_hotel_bookends: false })).toBe(false);
|
||||
expect(hotelBookendsOn({})).toBe(false);
|
||||
expect(hotelBookendsOn({ roadtrip_hotel_bookends: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the switch off', () => {
|
||||
it('ROADTRIP-BOOKENDS-027: the plan is the one stored, stop for stop, whatever stays the trip holds', () => {
|
||||
const hotel = stay(5, D1, D4, HOTEL);
|
||||
const plan = [
|
||||
planDay(D1, [stayStop(D1, 0, HOTEL, { name: 'H' }), visit(D1, 1, { name: 'P1' })]),
|
||||
planDay(D2, [visit(D2, 0, { name: 'P2' })]),
|
||||
planDay(D4, []),
|
||||
];
|
||||
const read = (on: boolean) => (on ? seatNightBookends(plan, days, [hotel]) : plan);
|
||||
const off = read(hotelBookendsOn({}));
|
||||
expect(off).toBe(plan);
|
||||
expect(off.map(shape)).toEqual([['H', 'P1'], ['P2'], []]);
|
||||
// Only the switch set on seats anything.
|
||||
expect(read(hotelBookendsOn({ roadtrip_hotel_bookends: true })).map(shape)).toEqual([
|
||||
['H', 'P1', 'evening:5'],
|
||||
['morning:5', 'P2', 'evening:5'],
|
||||
[],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { getDayBookendHotels, getDayOrder, hotelIsTheStop, type DayRef } from '../day/stay-bookends';
|
||||
import { closesTheDay, opensTheDay } from './carriers';
|
||||
import { stationary } from './dayWindow';
|
||||
import type { BookendPhase, PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
|
||||
/**
|
||||
* A booked night at both ends of the days around it.
|
||||
*
|
||||
* A stay's hotel is a stop on the day it is checked in on, where the booking put it
|
||||
* (#2354). The days after it did not know about it: a day after a booked night set off
|
||||
* from its first place, a day before one ended at its last, and with the days connected
|
||||
* the drive between them ran from one place to the other as if nobody slept anywhere. The
|
||||
* day plan has always drawn those two legs (`getDayBookendHotels`); this seats the same
|
||||
* two hotels on the road trip, as stops of their own at a day's edges, so the drive to
|
||||
* them and from them is timed, measured and drawn like any other.
|
||||
*
|
||||
* One rule for the browser and the server, run right after the terminals are seated
|
||||
* (`seatCarrierStops`) and before a day is asked whether it stands as one. Nothing is
|
||||
* stored: a bookend is the stay's place with an id that belongs to no assignment, and
|
||||
* every reader that files something by a stored stop's index passes it over
|
||||
* (`isStoredStop`).
|
||||
*/
|
||||
|
||||
/** A stay as the rule reads it: its nights, its place, and the booking behind it. */
|
||||
export interface BookendStay {
|
||||
id: number;
|
||||
place_id?: number | null;
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
place_lat?: number | null;
|
||||
place_lng?: number | null;
|
||||
place_name?: string | null;
|
||||
check_out?: string | null;
|
||||
reservation_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the terminals (-3e9), the automatic nights (-2e9) and the days the window
|
||||
* invents (-1e9), so a bookend's id never meets one of theirs.
|
||||
*/
|
||||
const BOOKEND_ID_BASE = -6_000_000_000;
|
||||
|
||||
/** The id a day's bookend goes by: one per day and phase, and the same on every run. */
|
||||
export function bookendAssignmentId(dayId: number, phase: BookendPhase): number {
|
||||
return BOOKEND_ID_BASE - dayId * 2 - (phase === 'evening' ? 1 : 0);
|
||||
}
|
||||
|
||||
/** Whether a trip's days start and end at the stay. Off unless it was switched on. */
|
||||
export function hotelBookendsOn(preferences: { roadtrip_hotel_bookends?: boolean | null }): boolean {
|
||||
return preferences.roadtrip_hotel_bookends === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the drive between two stops is a night spent at one hotel: one end is a
|
||||
* bookend and both stand on the same spot. Nothing is driven there, and nothing is asked.
|
||||
*/
|
||||
export function isStationaryJoin(
|
||||
from: Pick<RoadtripStop, 'bookend' | 'lat' | 'lng'>,
|
||||
to: Pick<RoadtripStop, 'bookend' | 'lat' | 'lng'>,
|
||||
): boolean {
|
||||
return !!(from.bookend || to.bookend) && from.lat === to.lat && from.lng === to.lng;
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg lookup that answers a night spent at one hotel with a leg going nowhere, ahead of
|
||||
* whatever is stored for the pair. A leg fetched for the same two points some other time
|
||||
* is a drive, and the night is not one.
|
||||
*/
|
||||
export function withStationaryJoins(
|
||||
lookup: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined,
|
||||
): (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined {
|
||||
return (from, to) => (isStationaryJoin(from, to) ? stationary(from) : lookup(from, to));
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays as the rule reads them, from the trip's stays and its bookings: each stay
|
||||
* once, with the earliest booking linked to it. A booking names its stay as text on some
|
||||
* rows, so the link is compared as a number.
|
||||
*/
|
||||
export function bookendStaysOf<S extends Omit<BookendStay, 'reservation_id'>>(
|
||||
stays: readonly S[],
|
||||
reservations: readonly { id: number; accommodation_id?: number | string | null }[],
|
||||
): BookendStay[] {
|
||||
const seen = new Set<number>();
|
||||
const out: BookendStay[] = [];
|
||||
for (const stay of stays) {
|
||||
if (seen.has(stay.id)) continue;
|
||||
seen.add(stay.id);
|
||||
const linked = reservations.filter(
|
||||
(r) => r.accommodation_id != null && r.accommodation_id !== '' && Number(r.accommodation_id) === stay.id,
|
||||
);
|
||||
out.push({
|
||||
id: stay.id,
|
||||
place_id: stay.place_id ?? null,
|
||||
start_day_id: stay.start_day_id,
|
||||
end_day_id: stay.end_day_id,
|
||||
place_lat: stay.place_lat ?? null,
|
||||
place_lng: stay.place_lng ?? null,
|
||||
place_name: stay.place_name ?? null,
|
||||
check_out: stay.check_out ?? null,
|
||||
reservation_id: linked.length ? Math.min(...linked.map((r) => r.id)) : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bookendStop(day: PlanDay, phase: BookendPhase, stay: BookendStay, ownerIndex: number): RoadtripStop {
|
||||
const checkingOut = phase === 'morning' && stay.end_day_id === day.dayId;
|
||||
return {
|
||||
bookend: {
|
||||
phase,
|
||||
accommodationId: stay.id,
|
||||
reservationId: stay.reservation_id ?? null,
|
||||
checkingOut,
|
||||
checkingIn: phase === 'evening' && stay.start_day_id === day.dayId,
|
||||
checkOut: checkingOut ? (stay.check_out ?? null) : null,
|
||||
},
|
||||
assignmentId: bookendAssignmentId(day.dayId, phase),
|
||||
ownerDayId: day.dayId,
|
||||
ownerIndex,
|
||||
placeId: stay.place_id!,
|
||||
name: stay.place_name ?? '',
|
||||
lat: stay.place_lat!,
|
||||
lng: stay.place_lng!,
|
||||
// No clock of its own. The check-out is the latest the room is handed back, not when
|
||||
// the drive leaves (#2357), and the evening's check-in is the stay's business: a stop
|
||||
// nobody can move or edit must never be the one the day is late for.
|
||||
time: null,
|
||||
leaveAt: null,
|
||||
checkInTime: null,
|
||||
dwellMinutes: 0,
|
||||
night: false,
|
||||
endDay: false,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: 'hotel',
|
||||
fillPercent: null,
|
||||
};
|
||||
}
|
||||
|
||||
const sameSpot = (a: BookendStay | undefined, b: BookendStay | undefined): boolean =>
|
||||
!!a && !!b && a.place_lat === b.place_lat && a.place_lng === b.place_lng;
|
||||
|
||||
/**
|
||||
* The plan's days with the night before and the night after seated at their edges.
|
||||
*
|
||||
* Which stay a day wakes up in and which one it sleeps in is the day plan's own choice
|
||||
* (`getDayBookendHotels`), read over the real nights only: a stay that begins and ends on
|
||||
* one day is slept in by nobody. The stays are read in id order, so the browser, which may
|
||||
* hold them in any order, and the server choose the same one where two overlap.
|
||||
*
|
||||
* The morning gets the stay slept in, unless the day already starts there, or starts on
|
||||
* landing or at a hire car's desk, where the traveller was not at the hotel. The evening
|
||||
* gets tonight's stay, unless the day already ends there, or ends at a departure or at the
|
||||
* desk the hire car goes back to. A day without a stop of its own gets both or neither: a
|
||||
* morning at one hotel and an evening at another is the drive between them, while a day
|
||||
* between two nights in one hotel, or a day that is only its check-out, is not driven.
|
||||
*
|
||||
* So a day with a bookend always has two stops or more, and stands as a day for that
|
||||
* reason and no other. Days that change nothing come back as they were, and so does the
|
||||
* plan when no day changes.
|
||||
*/
|
||||
export function seatNightBookends(plan: PlanDay[], days: readonly DayRef[], stays: readonly BookendStay[]): PlanDay[] {
|
||||
const dayOf = (id: number): DayRef | undefined => days.find((d) => d.id === id);
|
||||
const realNight = (stay: BookendStay): boolean => {
|
||||
const start = dayOf(stay.start_day_id);
|
||||
const end = dayOf(stay.end_day_id);
|
||||
return start && end ? getDayOrder(end, days) > getDayOrder(start, days) : stay.end_day_id > stay.start_day_id;
|
||||
};
|
||||
const nights = stays.filter((stay) => stay.place_id != null && realNight(stay)).sort((a, b) => a.id - b.id);
|
||||
if (!nights.length) return plan;
|
||||
|
||||
let changed = false;
|
||||
const out = plan.map((day) => {
|
||||
const ref = dayOf(day.dayId);
|
||||
if (!ref) return day;
|
||||
const hotels = getDayBookendHotels(ref, days, nights);
|
||||
const stops = day.stops;
|
||||
const first = stops[0];
|
||||
const last = stops[stops.length - 1];
|
||||
let head =
|
||||
!!hotels.morning &&
|
||||
!!hotels.morningIsSleptHere &&
|
||||
!(first && (hotelIsTheStop(hotels.morning, first) || opensTheDay(first.carrier?.role)));
|
||||
let tail =
|
||||
!!hotels.evening &&
|
||||
!!hotels.eveningIsOvernight &&
|
||||
!(last && (hotelIsTheStop(hotels.evening, last) || closesTheDay(last.carrier?.role)));
|
||||
if (!stops.length) head = tail = head && tail && !sameSpot(hotels.morning, hotels.evening);
|
||||
if (!head && !tail) return day;
|
||||
changed = true;
|
||||
const stored = stops.filter((stop) => !stop.carrier).length;
|
||||
return {
|
||||
...day,
|
||||
stops: [
|
||||
...(head ? [bookendStop(day, 'morning', hotels.morning!, 0)] : []),
|
||||
...stops,
|
||||
...(tail ? [bookendStop(day, 'evening', hotels.evening!, stored)] : []),
|
||||
],
|
||||
};
|
||||
});
|
||||
return changed ? out : plan;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withStationaryJoins } from './nightBookends';
|
||||
import { spillChains } from './nightSpill';
|
||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types';
|
||||
import type { RouteSegment } from './planning-types';
|
||||
@@ -571,3 +572,46 @@ describe('spillChains', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a booked night reached after midnight', () => {
|
||||
const bookend = (phase: 'morning' | 'evening') => ({
|
||||
phase,
|
||||
accommodationId: 5,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-028: the drive back to the hotel goes on the next card, and the morning waits for it', () => {
|
||||
// 20:00 + 1 h and four hours back to the hotel: in at 01:00. The night at the hotel
|
||||
// is one spot, so the morning sets out no earlier than that.
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '20:00', dwellMinutes: 60 }),
|
||||
stop({
|
||||
assignmentId: -6_000_000_003,
|
||||
name: 'Back to the hotel',
|
||||
lat: 52,
|
||||
lng: 13,
|
||||
bookend: bookend('evening'),
|
||||
}),
|
||||
]),
|
||||
day(2, 2, [
|
||||
stop({ assignmentId: -6_000_000_004, name: 'From the hotel', lat: 52, lng: 13, bookend: bookend('morning') }),
|
||||
stop({ assignmentId: 3, name: 'Dresden' }),
|
||||
]),
|
||||
];
|
||||
|
||||
const chains = spillChains(
|
||||
plan,
|
||||
[],
|
||||
withStationaryJoins(() => ({ ...everyLeg(240)(), vias: [] })),
|
||||
);
|
||||
|
||||
expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hamburg']);
|
||||
expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Back to the hotel', 'From the hotel', 'Dresden']);
|
||||
expect(chains[1]!.spills).toEqual([expect.objectContaining({ at: 0, count: 1, fromDayNumber: 1 })]);
|
||||
expect(chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual(['01:00', '01:00', '05:00']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,39 @@ export interface CarrierTerminal {
|
||||
at: string | null;
|
||||
}
|
||||
|
||||
/** Which end of a day a booked night stands at: the stay slept in, or tonight's. */
|
||||
export type BookendPhase = 'morning' | 'evening';
|
||||
|
||||
/**
|
||||
* A booked night standing at one end of a day it is no stop of: in the morning the stay
|
||||
* the traveller woke up in, in the evening the one they sleep in (`seatNightBookends`).
|
||||
*
|
||||
* The stay's own place, not an assignment: nothing about it can be moved, timed or given a
|
||||
* via point, and what it opens is the booking behind the night, or the stay itself when
|
||||
* nothing was booked for it.
|
||||
*/
|
||||
export interface NightBookend {
|
||||
phase: BookendPhase;
|
||||
accommodationId: number;
|
||||
/** The booking behind the night, the one a tap opens. Null for a stay entered without one. */
|
||||
reservationId: number | null;
|
||||
/** A morning on the day the stay is handed back. */
|
||||
checkingOut: boolean;
|
||||
/** An evening on the day the stay begins. */
|
||||
checkingIn: boolean;
|
||||
/**
|
||||
* The latest the room has to be handed back, on the morning it is: a label for the row,
|
||||
* never a time the drive leaves at (#2357).
|
||||
*/
|
||||
checkOut: string | null;
|
||||
}
|
||||
|
||||
export interface RoadtripStop {
|
||||
automaticNight?: AutomaticNight;
|
||||
/** Set on the two ends of a carrier ride. Such a stop is no place and belongs to no assignment. */
|
||||
carrier?: CarrierTerminal;
|
||||
/** Set on a booked night standing at the start or the end of a day. Belongs to no assignment. */
|
||||
bookend?: NightBookend;
|
||||
assignmentId: number;
|
||||
|
||||
ownerDayId: number;
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './vehicleRange';
|
||||
export * from './assemble';
|
||||
export * from './routeRun';
|
||||
export * from './carriers';
|
||||
export * from './nightBookends';
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
reanchorAfterReorder,
|
||||
reanchorByStopOrder,
|
||||
seamViaIndex,
|
||||
isStoredStop,
|
||||
} from './roadtripModel';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -1243,3 +1244,27 @@ describe('seamViaIndex', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStoredStop', () => {
|
||||
it('FE-ROADTRIP-MODEL-111: a stored stop is an assignment, not a marker, a terminal or a booked night at the edge', () => {
|
||||
expect(isStoredStop({})).toBe(true);
|
||||
expect(isStoredStop({ automaticNight: { phase: 'end', fromDayNumber: 1 } })).toBe(false);
|
||||
expect(
|
||||
isStoredStop({
|
||||
carrier: { reservationId: 1, type: 'flight', role: 'arrival', title: 'Flight', code: null, at: null },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStoredStop({
|
||||
bookend: {
|
||||
phase: 'morning',
|
||||
accommodationId: 1,
|
||||
reservationId: null,
|
||||
checkingOut: false,
|
||||
checkingIn: false,
|
||||
checkOut: null,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,6 +377,20 @@ export function computeSchedule(
|
||||
* at the bottom of the rail under "only X so far", which reads as the hotel missing
|
||||
* from the road trip altogether.
|
||||
*/
|
||||
/**
|
||||
* Whether a stop is one somebody stored on the day: an assignment, filed at its own
|
||||
* index. Not an automatic night, a terminal or a booked night at a day's edge, which the
|
||||
* plan seats between the stored stops and which borrow a stored stop's index to be seated
|
||||
* by. A reader looking a stop up by its day and index has to pass those three over, or it
|
||||
* finds the marker, the airport or the hotel in front of the stop it meant.
|
||||
*
|
||||
* Asked of the shape rather than of the id's sign: a stop added a moment ago carries a
|
||||
* temporary negative id until the server answers, and is stored all the same.
|
||||
*/
|
||||
export function isStoredStop(stop: Pick<RoadtripStop, 'automaticNight' | 'carrier' | 'bookend'>): boolean {
|
||||
return !stop.automaticNight && !stop.carrier && !stop.bookend;
|
||||
}
|
||||
|
||||
export function standsAsDay(stops: readonly { night?: boolean }[]): boolean {
|
||||
return stops.length > 1 || stops.some((stop) => stop.night === true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user