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:
Maurice
2026-09-23 14:18:59 +02:00
parent 7fa0434570
commit 514e2e62ee
60 changed files with 2563 additions and 134 deletions
@@ -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])
})
})
+49 -7
View File
@@ -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