mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): hire car desks, bookings under stops, the ride as one block
A hire car is no seam, but its desks are where the drive starts and ends: the pick-up opens its day when nothing timed comes first, the return closes the day it is handed back on, the road runs through both and the range budget starts from a full tank at the pick-up. The return is only placed when the booking names a return day and a located return desk. A table, tickets or a tour on the day hang under the stop they are pinned to or booked at as chips that open the booking; one for no stop is listed under the day. A ride that leaves and lands on the same day is one block on the rail and in the phone chain, not two terminal rows and a band. Both shells read the same helpers (carrierRide, stopBookings, roadtripRowModel). Server reads car bookings into the plan context; the MCP descriptions say so. The pt-BR locale had the three ride strings in Breton, fixed alongside. Refs #2428
This commit is contained in:
@@ -8,7 +8,7 @@ import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRou
|
||||
import type { DryPoint } from './roadtripModel'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import type { Reservation, RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
@@ -1131,7 +1131,7 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-049: the terminals carry the timetable and no number, the ride carries the booking and its minutes', () => {
|
||||
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes over both terminals with their timetable, and no number', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
@@ -1144,16 +1144,107 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-050: a terminal and the ride open the booking; the road out of the arrival offers no other ways', () => {
|
||||
const onOpenCarrier = vi.fn()
|
||||
it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
|
||||
const onOpenBooking = vi.fn()
|
||||
const onAskAlternatives = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenCarrier={onOpenCarrier} onAskAlternatives={onAskAlternatives} />)
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
|
||||
// One control for the whole ride, not one per terminal and one for the band.
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
fireEvent.click(screen.getByText('Hamburg Airport'))
|
||||
fireEvent.click(screen.getByText('LH 2020 · 1 h 10 min'))
|
||||
expect(onOpenCarrier).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenCarrier).toHaveBeenCalledWith(70)
|
||||
expect(onOpenBooking).toHaveBeenCalledTimes(2)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(70)
|
||||
// One shuffle: the road into the departure terminal. Not the ride, not the road out
|
||||
// of the arrival, whose via would be filed at the terminal's index.
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-051: a ride landing tomorrow leaves its departure as a row of its own, with the road into it', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), terminal('departure')]
|
||||
const overnight = day({
|
||||
stops,
|
||||
legs: [leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '18:00', departure: '18:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '21:00', departure: '22:00', anchored: true, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [overnight], totalStops: 1 })} onOpenBooking={vi.fn()} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
|
||||
expect(screen.getByText('Departure 13:20')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LH 2020 · 1 h 10 min')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Open booking')).toHaveLength(1)
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () => {
|
||||
const desk = (role: 'pickup' | 'return') =>
|
||||
stop({
|
||||
assignmentId: role === 'pickup' ? -3000000180 : -3000000181,
|
||||
name: role === 'pickup' ? 'Sixt Hauptbahnhof' : 'Sixt Airport',
|
||||
placeId: -90,
|
||||
time: role === 'pickup' ? '09:00' : '11:30',
|
||||
dwellMinutes: 0,
|
||||
carrier: { reservationId: 90, type: 'car', role, title: 'Sixt Hamburg', code: role === 'return' ? 'HAM' : null, at: role === 'pickup' ? '09:00' : '11:30' },
|
||||
})
|
||||
const booking = (over: Partial<Reservation>): Reservation =>
|
||||
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-052: the desks are rows on the road with the booking\'s clock, unnumbered, and the road between them is offered other ways only where it leaves a stored stop', () => {
|
||||
const stops = [desk('pickup'), stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' }), desk('return')]
|
||||
const rental = day({
|
||||
stops,
|
||||
legs: [leg(), leg(), leg()],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '09:20', departure: '09:50', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '10:50', departure: '11:20', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '11:30', departure: '11:30', anchored: true, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [rental], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={vi.fn()} />)
|
||||
expect(screen.getByText('Pick-up 09:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Return 11:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Sixt Airport'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(90)
|
||||
// Hamburg to Lübeck and Lübeck to the desk leave stored stops; the road out of the
|
||||
// pick-up desk does not.
|
||||
expect(screen.getAllByLabelText('Other ways')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
|
||||
const reservations = [
|
||||
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
|
||||
booking({ id: 12, title: 'Café Niederegger', type: 'restaurant', reservation_time: '2026-10-05T11:30', place_id: 20 }),
|
||||
booking({ id: 13, title: 'Elbphilharmonie', type: 'event', reservation_time: '2026-10-05T20:00', place_id: 999 }),
|
||||
booking({ id: 14, title: 'Tomorrow', type: 'event', day_id: 2, place_id: 10 }),
|
||||
booking({ id: 15, title: 'Hotel Hafen', type: 'hotel', place_id: 10 }),
|
||||
]
|
||||
const onOpenBooking = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
|
||||
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
|
||||
expect(screen.getByText('19:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
|
||||
expect(screen.getByText('Also booked this day')).toBeInTheDocument()
|
||||
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
|
||||
// Another day's booking is not this day's, and the night is the stay, not a chip.
|
||||
expect(screen.queryByText('Tomorrow')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Tisch Bullerei'))
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(11)
|
||||
// The chips are the stop's, not stops: the count stays at two.
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ChargingInfo from './ChargingInfo'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
|
||||
@@ -32,6 +32,8 @@ import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import { carrierIcon, rideText, terminalLine } from './carrierRide'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
|
||||
|
||||
@@ -104,10 +106,15 @@ interface RoadtripSidebarProps {
|
||||
/** Absent leaves every card open and its header unclickable. */
|
||||
onToggleDay?: (dayId: number) => void
|
||||
/**
|
||||
* Opens the booking a terminal or a ride stands for (#2428). Absent leaves the
|
||||
* terminal rows and the ride pills as plain text.
|
||||
* The trip's bookings, for the rail to hang under the stops they are for (#2428).
|
||||
* Absent draws the chain without them.
|
||||
*/
|
||||
onOpenCarrier?: (reservationId: number) => void
|
||||
reservations?: Reservation[]
|
||||
/**
|
||||
* Opens a booking: the ride a terminal stands for, the hire car a desk belongs to,
|
||||
* the table or the tickets under a stop. Absent leaves them all as plain text.
|
||||
*/
|
||||
onOpenBooking?: (reservationId: number) => void
|
||||
}
|
||||
|
||||
const MODE_ICON: Record<string, LucideIcon> = {
|
||||
@@ -823,48 +830,135 @@ function TerminalStop({ stop, entry, late, selected, continues, starts, onOpen }
|
||||
}
|
||||
|
||||
/**
|
||||
* The ride between two terminals, where a drive band would be.
|
||||
*
|
||||
* The booking and its minutes, no distance and no other ways: a flight has none. It opens
|
||||
* the booking, the same thing the terminals do, so everything about the ride is one tap
|
||||
* away from the chain.
|
||||
* One end of a ride inside the ride's block: the terminal's name and code, the
|
||||
* timetable's clock under it, and on the right the time the chain has the traveller
|
||||
* there. The same three things the lone terminal row shows, without a row of its own.
|
||||
*/
|
||||
function RideBand({ carrier, seg, onOpen }: {
|
||||
carrier: NonNullable<RoadtripStop['carrier']>
|
||||
function RideEnd({ stop, entry, late }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
late: ScheduleWarning[]
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const line = terminalLine(stop.carrier!, t, is12h)
|
||||
return (
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
<span className="min-w-0 break-words">{stop.name}</span>
|
||||
{stop.carrier!.code ? (
|
||||
<span className="shrink-0 font-geist font-medium text-content-faint" style={{ fontSize: FS.meta }}>{stop.carrier!.code}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{line ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{line}</span> : null}
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A ride that leaves and lands on the same day, as one thing in the chain: the booking
|
||||
* at the top, the terminal it leaves from, the minutes in the air, the terminal it lands
|
||||
* at. One block on one disc rather than three rows, because it IS one thing, a flight,
|
||||
* and three rows read as three places the day went to. The whole block opens the
|
||||
* booking; the road resumes below it as usual.
|
||||
*/
|
||||
function RideBlock({ departure, arrival, entries, late, seg, continues, starts, onOpen }: {
|
||||
departure: RoadtripStop
|
||||
arrival: RoadtripStop
|
||||
entries: [ScheduleEntry | undefined, ScheduleEntry | undefined]
|
||||
late: [ScheduleWarning[], ScheduleWarning[]]
|
||||
seg: RouteSegment | undefined
|
||||
continues: boolean
|
||||
starts?: boolean
|
||||
onOpen?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const carrier = departure.carrier!
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
const band = (
|
||||
<>
|
||||
<Icon size={12} strokeWidth={1.7} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium tabular-nums" style={{ fontSize: FS.meta }}>
|
||||
{rideText(carrier, seg)}
|
||||
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>
|
||||
</>
|
||||
<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 />
|
||||
<span className="min-w-0 truncate font-geist font-medium uppercase tracking-[0.09em]" style={{ fontSize: FS.label }}>
|
||||
{rideText(carrier, seg)}
|
||||
</span>
|
||||
</span>
|
||||
<RideEnd stop={departure} entry={entries[0]} late={late[0]} />
|
||||
<RideEnd stop={arrival} entry={entries[1]} late={late[1]} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings a stop carries, under its row: the table, the tickets, the tour, each a
|
||||
* chip with the booking panel's icon, its name and the clock it starts at. Chips because
|
||||
* a booking is a fact about the stop, not a stop of its own: the count, the numbering
|
||||
* and the drive are none the different for it. Each opens its booking.
|
||||
*
|
||||
* Under the stop's button rather than inside it, because a button cannot hold buttons.
|
||||
* The rail keeps its line through the row so the chain reads unbroken.
|
||||
*/
|
||||
function BookingChips({ bookings, continues, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
continues: boolean
|
||||
onOpen?: (reservationId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
return (
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
<span className="flex flex-col items-center" aria-hidden>
|
||||
{continues ? <span className="w-[1.5px] flex-1 rounded-sm bg-edge" /> : null}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{onOpen ? (
|
||||
<Tooltip label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="my-1.5 flex w-full items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-start text-content-muted transition-colors hover:bg-surface-selected focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
|
||||
>
|
||||
{band}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="my-1.5 flex items-center gap-1.5 rounded-lg bg-surface-tertiary py-1 pe-2 ps-2 text-content-muted">
|
||||
{band}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap gap-1 px-1.5 pb-1.5">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={10} strokeWidth={2} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{r.title}</span>
|
||||
{clock ? <span className="shrink-0 font-medium tabular-nums text-content-faint">{formatClockTime(clock, is12h)}</span> : null}
|
||||
</>
|
||||
)
|
||||
const chip = 'inline-flex h-[20px] max-w-full items-center gap-1 rounded-md border border-edge bg-surface-card px-1.5 text-content-secondary'
|
||||
return onOpen ? (
|
||||
<Tooltip key={r.id} label={t('roadtrip.ride.open')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(r.id)}
|
||||
className={`${chip} transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span key={r.id} className={chip} style={{ fontSize: FS.meta }}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1411,7 +1505,7 @@ function SpillBlock({ spill, children }: {
|
||||
* move, a stay edit or a refuel offer still names the day the server knows it by. See
|
||||
* `nightSpill.ts`.
|
||||
*/
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: {
|
||||
onFocusPoint?: RoadtripSidebarProps['onFocusPoint']
|
||||
day: RoadtripDay
|
||||
/** Folded down to the header, and off the map with it. */
|
||||
@@ -1425,7 +1519,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
drag: DragState
|
||||
onAskAlternatives?: RoadtripSidebarProps['onAskAlternatives']
|
||||
openAlternatives?: RoadtripSidebarProps['openAlternatives']
|
||||
onOpenCarrier?: RoadtripSidebarProps['onOpenCarrier']
|
||||
onOpenBooking?: RoadtripSidebarProps['onOpenBooking']
|
||||
reservations?: Reservation[]
|
||||
onEditStay?: RoadtripSidebarProps['onEditStay']
|
||||
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
|
||||
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
|
||||
@@ -1442,6 +1537,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
// Which disc the picker hangs under, and for which stop. One at a time: two open
|
||||
// popovers over the same rail is two answers to one question.
|
||||
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
|
||||
const bookings = useMemo(() => dayBookings(day, reservations ?? []), [day, reservations])
|
||||
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
|
||||
const { t, language } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
@@ -1464,7 +1560,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
|
||||
* still stored on the day it set off from (`nightSpill.ts`).
|
||||
*/
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
const latenessAt = (i: number): ScheduleWarning[] =>
|
||||
day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement | null => {
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
@@ -1482,31 +1580,50 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
// Every finding at this index is read on its own. Taking the first match let an
|
||||
// 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 = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||
// A terminal: not draggable, not numbered, not a place. Between a departure and its
|
||||
// arrival the leg is the ride, drawn as the booking; behind an arrival the road goes
|
||||
// on as usual, without other ways to drive it (`legReroutable`).
|
||||
const lateness = latenessAt(i)
|
||||
// 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
|
||||
// tomorrow, a desk) is a row. Behind either the road goes on as usual, without other
|
||||
// ways to drive it (`legReroutable`).
|
||||
if (stop.carrier) {
|
||||
const prev = day.stops[i - 1]
|
||||
const next = day.stops[i + 1]
|
||||
const ride = stop.carrier.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId
|
||||
const open = onOpenCarrier ? () => onOpenCarrier(stop.carrier!.reservationId) : undefined
|
||||
const sameRide = (other: RoadtripStop | undefined): boolean => other?.carrier?.reservationId === stop.carrier!.reservationId
|
||||
if (stop.carrier.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return null
|
||||
const ride = stop.carrier.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)
|
||||
const open = onOpenBooking ? () => onOpenBooking(stop.carrier!.reservationId) : undefined
|
||||
// The index the road leaves from: the arrival's when the block holds both ends.
|
||||
const tail = ride ? i + 1 : i
|
||||
const after = day.stops[tail + 1]
|
||||
return (
|
||||
<li key={stop.assignmentId}>
|
||||
<TerminalStop
|
||||
stop={stop}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={lateness}
|
||||
selected={false}
|
||||
continues={i < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
{ride ? (
|
||||
<RideBand carrier={stop.carrier} seg={day.legs[i]} onOpen={open} />
|
||||
) : i < last && (!next?.automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand leg={day.legs[i]} />
|
||||
<RideBlock
|
||||
departure={stop}
|
||||
arrival={next!}
|
||||
entries={[day.schedule.entries[i], day.schedule.entries[i + 1]]}
|
||||
late={[lateness, latenessAt(i + 1)]}
|
||||
seg={day.legs[i]}
|
||||
continues={tail < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
) : (
|
||||
<TerminalStop
|
||||
stop={stop}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={lateness}
|
||||
selected={false}
|
||||
continues={i < last}
|
||||
starts={i === 0}
|
||||
onOpen={open}
|
||||
/>
|
||||
)}
|
||||
{tail < last && (!after?.automaticNight || day.legs[tail]?.distance !== 0) ? (
|
||||
<DriveBand leg={day.legs[tail]} />
|
||||
) : null}
|
||||
{i < last && !ride ? (day.legVias[i] ?? []).map((via, vi) => (
|
||||
{tail < last ? (day.legVias[tail] ?? []).map((via, vi) => (
|
||||
<RouteViaStop key={`via-${vi}-${via.lat},${via.lng}`} via={via} />
|
||||
)) : null}
|
||||
</li>
|
||||
@@ -1578,6 +1695,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
)}
|
||||
{bookings.atStop.has(i) ? (
|
||||
<BookingChips bookings={bookings.atStop.get(i)!} continues={i < last} onOpen={onOpenBooking} />
|
||||
) : null}
|
||||
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
|
||||
<DriveBand
|
||||
leg={day.legs[i]}
|
||||
@@ -1789,6 +1909,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenCarrier, on
|
||||
</React.Fragment>
|
||||
)
|
||||
))}
|
||||
{/* Booked on this day, for no stop the day drives to: the rail has nowhere
|
||||
better to put them and leaving them out would make the mode look like it
|
||||
lost them. */}
|
||||
{bookings.loose.length ? (
|
||||
<li className="mt-1.5">
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span aria-hidden />
|
||||
<span className={`${STAT_LABEL} px-1.5`} style={{ fontSize: FS.micro }}>{t('roadtrip.bookings.loose')}</span>
|
||||
</div>
|
||||
<BookingChips bookings={bookings.loose} continues={false} onOpen={onOpenBooking} />
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
{picking ? (
|
||||
<StopKindPicker
|
||||
@@ -1889,7 +2021,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
|
||||
* them is a pill just for being a number.
|
||||
*/
|
||||
export default function RoadtripSidebar({
|
||||
routes, selectedAssignmentId, onSelectStop, onOpenCarrier, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
routes, selectedAssignmentId, onSelectStop, onOpenBooking, reservations, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
|
||||
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
@@ -1947,7 +2079,8 @@ export default function RoadtripSidebar({
|
||||
drag={drag}
|
||||
onAskAlternatives={onAskAlternatives}
|
||||
openAlternatives={openAlternatives}
|
||||
onOpenCarrier={onOpenCarrier}
|
||||
onOpenBooking={onOpenBooking}
|
||||
reservations={reservations}
|
||||
onEditStay={onEditStay}
|
||||
onSetStopKind={onSetStopKind}
|
||||
onSetStopFill={onSetStopFill}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Bus, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { Bus, Car, Plane, Sailboat, Ship, Train, type LucideIcon } from 'lucide-react'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import type { CarrierTerminal, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* How a ride and its two terminals read on the rail and in the phone chain (#2428).
|
||||
* How a ride and its two terminals, and a hire car's two desks, read on the rail and in
|
||||
* the phone chain (#2428).
|
||||
*
|
||||
* One module for both shells, because the two would otherwise carry the same icon table
|
||||
* and the same three sentences each, and the duplication budget does not stretch to that.
|
||||
@@ -18,6 +19,7 @@ const CARRIER_ICON: Record<string, LucideIcon> = {
|
||||
ferry: Sailboat,
|
||||
cruise: Ship,
|
||||
bus: Bus,
|
||||
car: Car,
|
||||
}
|
||||
|
||||
export function carrierIcon(type: string): LucideIcon {
|
||||
@@ -31,7 +33,7 @@ export function carrierIcon(type: string): LucideIcon {
|
||||
export function terminalLine(carrier: CarrierTerminal, t: TranslationFn, is12h: boolean): string | null {
|
||||
if (!carrier.at) return null
|
||||
const time = formatClockTime(carrier.at, is12h)
|
||||
return carrier.role === 'departure' ? t('roadtrip.ride.departure', { time }) : t('roadtrip.ride.arrival', { time })
|
||||
return t(`roadtrip.ride.${carrier.role}`, { time })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -536,23 +536,50 @@ describe('roadtripRows with a ride (#2428)', () => {
|
||||
legs: [seg(0), ride, seg(2)],
|
||||
})
|
||||
|
||||
it('FE-RTROW-045: the terminals are stop rows without a number, and the numbering skips them', () => {
|
||||
const rows = stopRows(roadtripRows(flightDay()))
|
||||
expect(rows.map(r => [r.stop.name, r.number])).toEqual([
|
||||
it('FE-RTROW-045: a same-day ride is one row holding both terminals, unnumbered, and the numbering skips them', () => {
|
||||
const rows = roadtripRows(flightDay())
|
||||
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([
|
||||
['Bremen', 1],
|
||||
['Munich Airport', null],
|
||||
['Hamburg Airport', null],
|
||||
['Hotel', 2],
|
||||
])
|
||||
const rideRow = rows.find(r => r.kind === 'ride')
|
||||
expect(rideRow?.kind === 'ride' && [rideRow.departure.stop.name, rideRow.departure.number, rideRow.arrival.stop.name, rideRow.arrival.number])
|
||||
.toEqual(['Munich Airport', null, 'Hamburg Airport', null])
|
||||
expect(destinationCount(flightDay())).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-RTROW-046: between a departure and its arrival the row is the ride, not a leg; the road out of the arrival is a leg', () => {
|
||||
it('FE-RTROW-046: the ride row stands where the departure stood and carries the booking; the road out of the arrival follows it as a leg', () => {
|
||||
const rows = roadtripRows(flightDay())
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'ride', 'stop', 'leg', 'stop'])
|
||||
const rideRow = rows[3]
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'ride', 'leg', 'stop'])
|
||||
const rideRow = rows[2]
|
||||
expect(rideRow.kind === 'ride' && rideRow.carrier.title).toBe('LH 2020')
|
||||
expect(rideRow.kind === 'ride' && rideRow.seg).toBe(ride)
|
||||
expect(rideRow.kind === 'ride' && rideRow.arrival.time).toBe(rideRow.kind === 'ride' ? rideRow.arrival.entry?.arrival ?? null : null)
|
||||
// The leg after the ride is the arrival's, at the arrival's index.
|
||||
expect(rows[3]).toMatchObject({ kind: 'leg', index: 2 })
|
||||
})
|
||||
|
||||
it('FE-RTROW-049: a ride landing tomorrow leaves its departure as a lone stop row, and the arrival opens the next day as one', () => {
|
||||
const rows = roadtripRows(day([stop('Bremen'), terminal('Munich Airport', 'departure')], { legs: [seg(0)] }))
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
const landing = roadtripRows(day([terminal('Hamburg Airport', 'arrival', '07:00'), stop('Hotel')], { legs: [seg(0)] }))
|
||||
expect(landing.map(r => r.kind)).toEqual(['stop', 'leg', 'stop'])
|
||||
expect(stopRows(landing).map(r => [r.stop.name, r.number])).toEqual([['Hamburg Airport', null], ['Hotel', 1]])
|
||||
})
|
||||
|
||||
it('FE-RTROW-050: a hire car\'s desks are unnumbered stop rows the road runs through', () => {
|
||||
const desk = (name: string, role: 'pickup' | 'return') =>
|
||||
stop(name, {
|
||||
assignmentId: role === 'pickup' ? -3000000018 : -3000000019,
|
||||
placeId: -9,
|
||||
carrier: { reservationId: 9, type: 'car', role, title: 'Sixt', code: null, at: '09:00' },
|
||||
})
|
||||
const rows = roadtripRows(day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] }))
|
||||
expect(rows.map(r => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop'])
|
||||
expect(stopRows(rows).map(r => [r.stop.name, r.number])).toEqual([['Sixt Hbf', null], ['Bremen', 1], ['Sixt Airport', null]])
|
||||
const d = day([desk('Sixt Hbf', 'pickup'), stop('Bremen'), desk('Sixt Airport', 'return')], { legs: [seg(0), seg(1)] })
|
||||
expect(legReroutable(d, 0)).toBe(false)
|
||||
expect(legReroutable(d, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-RTROW-047: the road into a departure terminal can be offered other ways, nothing leaving a terminal can', () => {
|
||||
|
||||
@@ -22,10 +22,11 @@ export type RoadtripRow =
|
||||
| StopRow
|
||||
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
|
||||
/**
|
||||
* The ride between a departure terminal and its arrival (#2428): the booking, not a
|
||||
* drive. Drawn in place of the leg row, with the booking's minutes and no distance.
|
||||
* A ride that leaves and lands on the day (#2428), as one row: the booking with both
|
||||
* its terminals inside it, in place of two stop rows and the leg between them. The
|
||||
* booking's minutes, no distance, and the road goes on from `arrival`.
|
||||
*/
|
||||
| { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined }
|
||||
| { kind: 'ride'; index: number; carrier: CarrierTerminal; seg: RouteSegment | undefined; departure: StopRow; arrival: StopRow }
|
||||
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
|
||||
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
|
||||
|
||||
@@ -153,6 +154,13 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
})
|
||||
}
|
||||
|
||||
const next = day.stops[i + 1]
|
||||
const prev = day.stops[i - 1]
|
||||
const sameRide = (other: RoadtripStop | undefined): boolean =>
|
||||
!!stop.carrier && other?.carrier?.reservationId === stop.carrier.reservationId
|
||||
// The arrival of a same-day ride is inside the ride row its departure made.
|
||||
if (stop.carrier?.role === 'arrival' && prev?.carrier?.role === 'departure' && sameRide(prev)) return
|
||||
|
||||
const automatic = !!stop.automaticNight
|
||||
if (automatic) {
|
||||
rows.push({
|
||||
@@ -160,28 +168,44 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
|
||||
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
|
||||
time: day.schedule.entries[i]?.arrival ?? null,
|
||||
})
|
||||
} else if (stop.carrier?.role === 'departure' && next?.carrier?.role === 'arrival' && sameRide(next)) {
|
||||
rows.push({
|
||||
kind: 'ride',
|
||||
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),
|
||||
})
|
||||
// The road out of the arrival belongs to this row too; the pass below only
|
||||
// looks at the index it is on.
|
||||
pushLeg(i + 1)
|
||||
return
|
||||
} else {
|
||||
// A terminal is where the drive stops or resumes, not a place the trip is for: it
|
||||
// carries no number, the way a service stop carries none.
|
||||
// A terminal or a hire car's desk is where the drive stops or resumes, not a
|
||||
// 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))
|
||||
}
|
||||
|
||||
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
|
||||
// between two stops, so they are emitted here rather than in their own pass.
|
||||
pushLeg(i)
|
||||
})
|
||||
|
||||
// The leg AFTER stop `i`, plus the dry point that falls on it. Both belong between
|
||||
// two stops, so they are emitted with the stop before them rather than in a pass of
|
||||
// their own.
|
||||
function pushLeg(i: number): void {
|
||||
const stop = day.stops[i]!
|
||||
const seg = day.legs[i]
|
||||
const next = day.stops[i + 1]
|
||||
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
|
||||
rows.push({ kind: 'ride', index: i, carrier: stop.carrier, seg })
|
||||
} else if (i < day.stops.length - 1 && !isStationary(seg)) {
|
||||
if (i < day.stops.length - 1 && !isStationary(seg)) {
|
||||
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
|
||||
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
|
||||
if (dry) {
|
||||
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Car, Ticket, Utensils } from 'lucide-react'
|
||||
import { bookingClock, bookingIcon, dayBookings } from './stopBookings'
|
||||
import type { Reservation } from '../../types'
|
||||
|
||||
const booking = (over: Partial<Reservation>): Reservation =>
|
||||
({ id: 1, trip_id: 1, title: 'Booking', type: 'restaurant', status: 'confirmed', day_id: 1, ...over }) as Reservation
|
||||
|
||||
const day = {
|
||||
dayId: 1,
|
||||
stops: [
|
||||
{ assignmentId: -3000000140, placeId: -70, carrier: { reservationId: 70, type: 'flight', role: 'arrival' as const, title: 'LH', code: null, at: null } },
|
||||
{ assignmentId: 11, placeId: 110 },
|
||||
{ assignmentId: -2000000001, placeId: 110, automaticNight: { phase: 'end' as const, fromDayNumber: 1 } as never },
|
||||
{ assignmentId: 12, placeId: 120 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('dayBookings (#2428)', () => {
|
||||
it('FE-STOPBOOKINGS-001: a booking hangs under the stop it is pinned to, or under the stop at its place on its day, in clock order', () => {
|
||||
const { atStop, loose } = dayBookings(day, [
|
||||
booking({ id: 3, title: 'Late', reservation_time: '2026-10-05T20:00', assignment_id: 11 }),
|
||||
booking({ id: 2, title: 'Early', reservation_time: '2026-10-05T11:00', place_id: 110 }),
|
||||
booking({ id: 4, title: 'Untimed', place_id: 110 }),
|
||||
booking({ id: 5, title: 'Ticket', type: 'event', place_id: 120 }),
|
||||
])
|
||||
expect([...atStop.keys()]).toEqual([1, 3])
|
||||
expect(atStop.get(1)!.map(r => r.title)).toEqual(['Early', 'Late', 'Untimed'])
|
||||
expect(atStop.get(3)!.map(r => r.title)).toEqual(['Ticket'])
|
||||
expect(loose).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-002: a terminal and an automatic night hold nothing, even at the same place; a pinned booking needs no day', () => {
|
||||
// The night marker shares the hotel's place id; the booking still lands on the stop.
|
||||
const { atStop } = dayBookings(day, [booking({ id: 6, place_id: 110, day_id: 1 })])
|
||||
expect([...atStop.keys()]).toEqual([1])
|
||||
// Pinned to the assignment: on the stop whatever day the row names.
|
||||
const pinned = dayBookings(day, [booking({ id: 7, assignment_id: 12, day_id: 9 })])
|
||||
expect([...pinned.atStop.keys()]).toEqual([3])
|
||||
// A booking at the flight's negative place id is nobody's.
|
||||
expect(dayBookings(day, [booking({ id: 8, place_id: -70 })]).loose).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-003: the night, the hire car and the ride are drawn elsewhere; another day\'s booking is not this day\'s; a span ends on this day', () => {
|
||||
const { atStop, loose } = dayBookings(day, [
|
||||
booking({ id: 1, type: 'hotel', place_id: 110 }),
|
||||
booking({ id: 2, type: 'car', place_id: 110 }),
|
||||
booking({ id: 3, type: 'flight', place_id: 110 }),
|
||||
booking({ id: 4, type: 'train', place_id: 110 }),
|
||||
booking({ id: 5, title: 'Tomorrow', day_id: 2, place_id: 110 }),
|
||||
booking({ id: 6, title: 'Tour ends here', type: 'tour', day_id: 0, end_day_id: 1 }),
|
||||
])
|
||||
expect(atStop.size).toBe(0)
|
||||
expect(loose.map(r => r.title)).toEqual(['Tour ends here'])
|
||||
expect(dayBookings(day, [])).toEqual({ atStop: new Map(), loose: [] })
|
||||
})
|
||||
|
||||
it('FE-STOPBOOKINGS-004: the chip wears the booking panel\'s icon and the clock the booking starts at', () => {
|
||||
expect(bookingIcon('restaurant')).toBe(Utensils)
|
||||
expect(bookingIcon('car')).toBe(Car)
|
||||
expect(bookingIcon('nonsense')).toBe(Ticket)
|
||||
expect(bookingClock({ reservation_time: '2026-10-05T19:30' })).toBe('19:30')
|
||||
expect(bookingClock({ reservation_time: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { isCarrierType, isRentalType } from '@trek/shared/roadtrip'
|
||||
import { RES_ICONS } from '../Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import type { Reservation } from '../../types'
|
||||
import type { RoadtripStop } from '@trek/shared/roadtrip'
|
||||
|
||||
/**
|
||||
* The bookings a day carries besides its nights, its rides and its hire car: the table,
|
||||
* the tickets, the tour. On the road trip each hangs under the stop it is for, and one
|
||||
* that is for no stop on the day is listed under the day, so nothing booked goes unseen
|
||||
* in this mode.
|
||||
*
|
||||
* A booking is a stop's when it is pinned to the stop's assignment, or, failing that,
|
||||
* when it is for the stop's place on the stop's day: a table booked at the Bullerei on
|
||||
* the day the Bullerei is driven to is the Bullerei's, pinned or not. The night, the
|
||||
* ride and the hire car are left out because the rail already draws them as the stay,
|
||||
* the terminals and the desks.
|
||||
*/
|
||||
export interface DayBookings {
|
||||
/** By index into `day.stops`, earliest first. Indexes without a booking are absent. */
|
||||
atStop: Map<number, Reservation[]>
|
||||
/** On the day, at no stop of it. */
|
||||
loose: Reservation[]
|
||||
}
|
||||
|
||||
const EMPTY: DayBookings = { atStop: new Map(), loose: [] }
|
||||
|
||||
function drawnElsewhere(type: string): boolean {
|
||||
return type === 'hotel' || isRentalType(type) || isCarrierType(type)
|
||||
}
|
||||
|
||||
/** Earliest first, untimed last, id as the tiebreaker: the day plan's own order. */
|
||||
function byClock(a: Reservation, b: Reservation): number {
|
||||
const at = a.reservation_time || ''
|
||||
const bt = b.reservation_time || ''
|
||||
if (at !== bt) {
|
||||
if (!at) return 1
|
||||
if (!bt) return -1
|
||||
return at < bt ? -1 : 1
|
||||
}
|
||||
return a.id - b.id
|
||||
}
|
||||
|
||||
export function dayBookings(
|
||||
day: { dayId: number; stops: readonly Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'carrier' | 'automaticNight'>[] },
|
||||
reservations: readonly Reservation[],
|
||||
): DayBookings {
|
||||
if (!reservations.length) return EMPTY
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
const loose: Reservation[] = []
|
||||
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
|
||||
let index = r.assignment_id ? stops.findIndex((s, i) => real(i) && s.assignmentId === r.assignment_id) : -1
|
||||
const onDay = r.day_id === day.dayId || (r.end_day_id != null && r.end_day_id === day.dayId)
|
||||
if (index < 0 && onDay && r.place_id) index = stops.findIndex((s, i) => real(i) && s.placeId === r.place_id)
|
||||
if (index >= 0) {
|
||||
const list = atStop.get(index) ?? []
|
||||
list.push(r)
|
||||
atStop.set(index, list)
|
||||
} else if (onDay) {
|
||||
loose.push(r)
|
||||
}
|
||||
}
|
||||
for (const list of atStop.values()) list.sort(byClock)
|
||||
loose.sort(byClock)
|
||||
return atStop.size || loose.length ? { atStop, loose } : EMPTY
|
||||
}
|
||||
|
||||
/** The booking panel's icon for the type, so a table reads as a table here too. */
|
||||
export function bookingIcon(type: string): LucideIcon {
|
||||
return (RES_ICONS as Record<string, LucideIcon>)[type] ?? Ticket
|
||||
}
|
||||
|
||||
/** The clock the booking starts at, or null when it names none. */
|
||||
export function bookingClock(r: Pick<Reservation, 'reservation_time'>): string | null {
|
||||
return splitReservationDateTime(r.reservation_time).time || null
|
||||
}
|
||||
@@ -931,14 +931,40 @@ describe('a booking the traveller rides (#2428)', () => {
|
||||
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or one the traveller drives, changes nothing', async () => {
|
||||
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
||||
const unlocated = flight({ endpoints: [] })
|
||||
const hireCar = flight({ id: 71, type: 'car' })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, hireCar]))
|
||||
const taxi = flight({ id: 71, type: 'taxi' })
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-043: a hire car puts its desks on the road, one run through both, and no ride', async () => {
|
||||
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
||||
const stops: StopSpec[] = [{ id: 1, at: LUENEBURG, time: '10:00', dwell: 0 }, { id: 2, at: BERLIN, time: '14:00', dwell: 0 }]
|
||||
const hireCar = flight({
|
||||
id: 71,
|
||||
type: 'car',
|
||||
title: 'Sixt',
|
||||
reservation_time: '09:00',
|
||||
reservation_end_time: '18:00',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Sixt Hamburg', code: null, lat: HAMBURG[0], lng: HAMBURG[1], timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'Sixt Berlin', code: null, lat: BERLIN[0] + 0.01, lng: BERLIN[1], timezone: null, local_time: null, local_date: null },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [hireCar]))
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const d = result.current.days[0]
|
||||
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 1', 'Stop 2', 'return'])
|
||||
// One road, the whole day, desk to desk.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'driving'])
|
||||
expect(d.schedule.entries[0]).toMatchObject({ arrival: '09:00', departure: '09:00' })
|
||||
expect(result.current.totalStops).toBe(2)
|
||||
expect(result.current.lines).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,14 +4,15 @@ import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide'
|
||||
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 { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { 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 { CarrierTerminal, DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../../../types'
|
||||
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.
|
||||
@@ -186,36 +187,101 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
|
||||
cycling: Bike,
|
||||
}
|
||||
|
||||
/** One end of a ride inside its block: name, code, the timetable line, the clock. */
|
||||
function RideEnd({ row, chrome }: { row: StopRow; chrome: RowChrome }) {
|
||||
const carrier = row.stop.carrier!
|
||||
const line = terminalLine(carrier, chrome.t, chrome.is12h)
|
||||
return (
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-[6px]">
|
||||
<span className="line-clamp-2 text-[0.875rem] font-semibold leading-[1.25] text-m-ink">{row.stop.name}</span>
|
||||
{carrier.code && <span className="flex-none font-geist text-[0.65625rem] font-medium text-m-faint">{carrier.code}</span>}
|
||||
</span>
|
||||
{line && <span className="mt-[2px] block font-geist text-[0.65625rem] font-medium text-m-muted">{line}</span>}
|
||||
</span>
|
||||
{row.time && (
|
||||
<span dir="ltr" className={`whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
|
||||
{formatClockTime(row.time, chrome.is12h)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ride between two terminals, where a leg pill would be (#2428): the booking and its
|
||||
* minutes, no distance and no other ways of driving it. The pill is the tap target and
|
||||
* opens the booking, the same sheet the map's endpoint badge opens.
|
||||
* A ride that leaves and lands on the day, as one block in the chain (#2428): the
|
||||
* booking and its minutes at the top, the terminal it leaves from, the terminal it lands
|
||||
* at. One block on one disc, because it is one thing, and three rows read as three
|
||||
* places the day went to. The block is the tap target and opens the booking, the same
|
||||
* sheet the map's endpoint badge opens.
|
||||
*/
|
||||
export function RtRideRow({ carrier, seg, onOpen }: {
|
||||
carrier: CarrierTerminal
|
||||
seg: RouteSegment | undefined
|
||||
export function RtRideRow({ row, chrome, onOpen }: {
|
||||
row: Extract<RoadtripRow, { kind: 'ride' }>
|
||||
chrome: RowChrome
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const Icon = carrierIcon(carrier.type)
|
||||
const Icon = carrierIcon(row.carrier.type)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{rideText(carrier, seg)}</span>
|
||||
<span className="flex items-center gap-[6px] text-m-muted">
|
||||
<Icon size={12} strokeWidth={2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.06em]">{rideText(row.carrier, row.seg)}</span>
|
||||
</span>
|
||||
<RideEnd row={row.departure} chrome={chrome} />
|
||||
<RideEnd row={row.arrival} chrome={chrome} />
|
||||
</>
|
||||
)
|
||||
const cls = 'my-1 flex min-w-0 flex-col gap-[7px] rounded-[16px] bg-[color:var(--m-ic)] px-[12px] py-[9px] text-start'
|
||||
return (
|
||||
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}>
|
||||
<span className="flex min-h-[40px] flex-col items-center" aria-hidden="true">
|
||||
<span className="w-[2px] flex-1" style={{ backgroundImage: 'repeating-linear-gradient(var(--m-conn) 0 4px, transparent 4px 8px)' }} />
|
||||
<div className="grid items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<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">
|
||||
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
{onOpen ? (
|
||||
<button type="button" onClick={onOpen} className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px] text-start">
|
||||
{body}
|
||||
</button>
|
||||
<button type="button" onClick={onOpen} className={cls}>{body}</button>
|
||||
) : (
|
||||
<span className="my-1.5 flex min-w-0 items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">{body}</span>
|
||||
<span className={cls}>{body}</span>
|
||||
)}
|
||||
<span className="h-10 w-10 flex-none" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings a stop carries, under its row (#2428): the table, the tickets, the tour,
|
||||
* each a chip with the booking panel's icon, its name and the clock it starts at. Chips
|
||||
* because a booking is a fact about the stop and not a stop of its own. Each opens its
|
||||
* booking; without `onOpen` they are plain.
|
||||
*/
|
||||
export function RtBookingChips({ bookings, chrome, onOpen }: {
|
||||
bookings: Reservation[]
|
||||
chrome: RowChrome
|
||||
onOpen?: (reservation: Reservation) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-x-[10px] pb-2" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span aria-hidden="true" />
|
||||
<span className="flex flex-wrap gap-[6px]">
|
||||
{bookings.map(r => {
|
||||
const Icon = bookingIcon(r.type)
|
||||
const clock = bookingClock(r)
|
||||
const body = (
|
||||
<>
|
||||
<Icon size={11} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-[0.75rem] font-medium text-m-ink">{r.title}</span>
|
||||
{clock && <span className="flex-none font-geist text-[0.65625rem] tabular-nums text-m-faint">{formatClockTime(clock, chrome.is12h)}</span>}
|
||||
</>
|
||||
)
|
||||
const cls = 'inline-flex h-[28px] max-w-full items-center gap-[5px] rounded-full border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[10px]'
|
||||
return onOpen ? (
|
||||
<button key={r.id} type="button" onClick={() => onOpen(r)} className={`${cls} text-start`}>{body}</button>
|
||||
) : (
|
||||
<span key={r.id} className={cls}>{body}</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { Fragment, useMemo, useRef } from 'react'
|
||||
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
|
||||
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
|
||||
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
|
||||
@@ -6,7 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor'
|
||||
import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { RtAutoRow, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import { RtAutoRow, 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'
|
||||
@@ -17,6 +17,8 @@ import { formatClockTime } from '../../../../utils/formatters'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
import type { MTripTabPanelProps } from '../MTripShell'
|
||||
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import { dayBookings } from '../../../../components/Roadtrip/stopBookings'
|
||||
import type { Reservation } from '../../../../types'
|
||||
|
||||
/**
|
||||
* The road trip tab: one day of the drive, as a chain or on the map.
|
||||
@@ -82,6 +84,26 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
else shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
|
||||
}
|
||||
|
||||
// The bookings under the stage's stops and the ones the stage has for no stop, keyed
|
||||
// by the stop's assignment because the rows carry no index. Opened the way the place
|
||||
// sheet opens its linked bookings: a transport in its sheet, anything else in its
|
||||
// editor, and only for somebody allowed to.
|
||||
const bookings = useMemo(() => {
|
||||
const byIndex = stage ? dayBookings(stage, planner.reservations) : null
|
||||
const atStop = new Map<number, Reservation[]>()
|
||||
if (stage && byIndex) for (const [i, list] of byIndex.atStop) atStop.set(stage.stops[i]!.assignmentId, list)
|
||||
return { atStop, loose: byIndex?.loose ?? [] }
|
||||
}, [stage, planner.reservations])
|
||||
const openBooking = (res: Reservation) => {
|
||||
if (planner.TRANSPORT_TYPES.has(res.type)) {
|
||||
shell.openSheet('transport', { reservationId: res.id })
|
||||
return
|
||||
}
|
||||
if (!planner.can('reservation_edit', planner.trip)) return
|
||||
planner.setEditingReservation(res)
|
||||
planner.setShowReservationModal(true)
|
||||
}
|
||||
|
||||
// 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
|
||||
// jumps between them is a control you have to find twice. The band and the margins
|
||||
@@ -226,20 +248,23 @@ 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') {
|
||||
const chips = bookings.atStop.get(row.stop.assignmentId)
|
||||
return (
|
||||
<RtStopRow
|
||||
key={`s${i}`}
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces && !row.stop.carrier
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
<Fragment key={`s${i}`}>
|
||||
<RtStopRow
|
||||
row={row}
|
||||
chrome={chrome}
|
||||
onOpen={() => openStop(row)}
|
||||
onPickKind={canEditPlaces && !row.stop.carrier
|
||||
? () => shell.openSheet('rtkind', {
|
||||
placeId: row.stop.placeId,
|
||||
stopType: row.stop.stopType ?? null,
|
||||
name: row.stop.name,
|
||||
})
|
||||
: undefined}
|
||||
/>
|
||||
{chips && <RtBookingChips bookings={chips} chrome={chrome} onOpen={openBooking} />}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (row.kind === 'leg') {
|
||||
@@ -259,7 +284,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
)
|
||||
}
|
||||
if (row.kind === 'ride') {
|
||||
return <RtRideRow key={`r${i}`} carrier={row.carrier} seg={row.seg} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
|
||||
return <RtRideRow key={`r${i}`} row={row} chrome={chrome} onOpen={() => shell.openSheet('transport', { reservationId: row.carrier.reservationId })} />
|
||||
}
|
||||
if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
|
||||
if (row.kind === 'spill') {
|
||||
@@ -284,6 +309,15 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{bookings.loose.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="grid gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr' }}>
|
||||
<span aria-hidden="true" />
|
||||
<span className="mb-[6px] block font-geist text-[0.65625rem] font-semibold uppercase tracking-[0.08em] text-m-faint">{t('roadtrip.bookings.loose')}</span>
|
||||
</div>
|
||||
<RtBookingChips bookings={bookings.loose} chrome={chrome} onOpen={openBooking} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -599,9 +599,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onFocusPoint={focusRoadtripPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={(placeId, assignmentId) => handlePlaceClick(placeId, assignmentId)}
|
||||
onOpenCarrier={(rid) => {
|
||||
reservations={reservations}
|
||||
onOpenBooking={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
if (!r) return
|
||||
// The day plan's own split: a transport has a detail view with
|
||||
// an edit button on it, a table or a ticket only has its editor.
|
||||
if (TRANSPORT_TYPES.has(r.type)) setMapTransportDetail(r)
|
||||
else openLinkedReservation?.(r)
|
||||
}}
|
||||
onReorderStop={can('day_edit', trip) ? reorderRoadtripStop : undefined}
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
|
||||
@@ -30,7 +30,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 } from '@trek/shared/roadtrip'
|
||||
import { carrierReservationIds, type CarrierTerminal } from '@trek/shared/roadtrip'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||
@@ -1663,7 +1663,7 @@ 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: 'departure' | 'arrival' | null } | null = null
|
||||
let best: { dayId: number; afterIndex: number; offRouteKm: number; terminal: CarrierTerminal['role'] | null } | 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
|
||||
@@ -1737,8 +1737,8 @@ export function useTripPlanner() {
|
||||
*/
|
||||
const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => {
|
||||
const anchor = anchorFor(lat, lng)
|
||||
// Nothing is stopped at on a flight. Behind an arrival terminal is a road, and a stop
|
||||
// there is the first stop after landing.
|
||||
// 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
|
||||
for (const day of roadtripRoutes.days) {
|
||||
const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex)
|
||||
|
||||
Reference in New Issue
Block a user