fix(roadtrip): a late check-out morning says so, a road driven twice takes its via

The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
This commit is contained in:
Maurice
2026-09-23 16:01:40 +02:00
parent e6e520b78f
commit 05f533dd0d
34 changed files with 307 additions and 41 deletions
@@ -1479,6 +1479,23 @@ describe('RoadtripSidebar with a booked night at the edge of the day', () => {
expect(screen.getByText('11:40')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-074: a check-out morning left after the room is handed back says so, one left in time does not', () => {
const late = loop({
schedule: {
entries: ['12:27', '13:27', '14:27', '15:27'].map(arrival => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })),
warnings: [],
},
})
const { unmount } = wrap(<RoadtripSidebar routes={routes({ days: [late] })} />)
expect(screen.getByText('until 10:00')).toBeInTheDocument()
expect(screen.getByText('Leaves after check-out')).toBeInTheDocument()
unmount()
wrap(<RoadtripSidebar routes={routes({ days: [loop()] })} />)
expect(screen.getByText('until 10:00')).toBeInTheDocument()
expect(screen.queryByText('Leaves after check-out')).toBeNull()
})
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()} />,
@@ -18,7 +18,7 @@ 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, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel'
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle } from './nightBookend'
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, leavesAfterCheckOut } from './nightBookend'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
@@ -876,7 +876,8 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
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
const afterCheckOut = leavesAfterCheckOut(reading, entry)
const badges = afterCheckOut || 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">
@@ -887,6 +888,12 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
{meta || badges ? (
<span className="flex flex-wrap items-center gap-1">
{meta ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{meta}</span> : null}
{afterCheckOut ? (
<span className="inline-flex items-center gap-1 font-medium text-warning" style={{ fontSize: FS.meta }}>
<AlertTriangle size={10} aria-hidden="true" />
{t('roadtrip.bookend.afterCheckOut')}
</span>
) : null}
{driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
{late.map(w => <LateBadge key={w.code} late={w} />)}
</span>
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import { BedDouble } from 'lucide-react'
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, staysAtTheirPlaces } from './nightBookend'
import { BOOKEND_ICON, bookendBooking, bookendMeta, bookendTitle, leavesAfterCheckOut, staysAtTheirPlaces } from './nightBookend'
import type { BookendReading } from './roadtripRowModel'
// FE-BOOKEND-001 to FE-BOOKEND-006
// FE-BOOKEND-001 to FE-BOOKEND-008
const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
phase: 'morning',
@@ -38,6 +38,22 @@ describe('a booked night as the rail and the phone say it', () => {
expect(bookendMeta(reading({ phase: 'evening', variant: 'back' }), t, false)).toBeNull()
})
it('FE-BOOKEND-007: a day that sets out after the room is handed back says so', () => {
const checkOut = reading({ variant: 'checkOut', until: '10:00' })
expect(leavesAfterCheckOut(checkOut, { arrival: '12:27', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:01', dayOffset: 0 })).toBe(true)
expect(leavesAfterCheckOut(checkOut, { arrival: '10:00', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(checkOut, { arrival: '08:30', dayOffset: 0 })).toBe(false)
// Past midnight it is later than any hour of the morning before.
expect(leavesAfterCheckOut(checkOut, { arrival: '01:00', dayOffset: 1 })).toBe(true)
})
it('FE-BOOKEND-008: nothing to say without a check-out hour or a clock to hold it against', () => {
expect(leavesAfterCheckOut(reading(), { arrival: '12:27', dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), { arrival: null, dayOffset: 0 })).toBe(false)
expect(leavesAfterCheckOut(reading({ variant: 'checkOut', until: '10:00' }), undefined)).toBe(false)
})
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.
@@ -1,5 +1,6 @@
import { BedDouble, type LucideIcon } from 'lucide-react'
import { formatClockTime } from '../../utils/formatters'
import { parseClock, type ScheduleEntry } from './roadtripModel'
import type { TranslationFn } from '../../types'
import type { BookendReading } from './roadtripRowModel'
@@ -29,6 +30,22 @@ export function bookendMeta(reading: BookendReading, t: TranslationFn, is12h: bo
return reading.until ? t('roadtrip.stay.until', { time: formatClockTime(reading.until, is12h) }) : null
}
/**
* Whether the plan leaves the stay later than the room is handed back. The check-out hour
* never moves the start of the day, so a day that sets out at 12:30 from a room given up
* at 10:00 is a plan worth a second look, and the row says so instead of standing the two
* clocks side by side without a word.
*/
export function leavesAfterCheckOut(
reading: BookendReading,
entry: Pick<ScheduleEntry, 'arrival' | 'dayOffset'> | undefined,
): boolean {
const until = parseClock(reading.until)
const leaves = parseClock(entry?.arrival)
if (until === null || leaves === null) return false
return leaves + (entry?.dayOffset ?? 0) * 24 * 60 > until
}
/** The fields of a trip place the hotel of a stay is read from. */
export interface HotelPlace {
id: number
@@ -4,7 +4,7 @@ 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 { BOOKEND_ICON, bookendMeta, bookendTitle, leavesAfterCheckOut } 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'
@@ -207,6 +207,7 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
}) {
const { t } = chrome
const meta = bookendMeta(bookend, t, chrome.is12h)
const afterCheckOut = leavesAfterCheckOut(bookend, row.entry)
return (
<TapRow onOpen={onOpen}>
<span className="flex justify-center">
@@ -216,9 +217,10 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
</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) && (
{(meta || afterCheckOut || 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>}
{afterCheckOut && <Mark tone="warn" icon={<AlertTriangle size={10} strokeWidth={2} />}>{t('roadtrip.bookend.afterCheckOut')}</Mark>}
{row.warning && warningMark(row.warning, chrome)}
</span>
)}
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-150
// FE-TP-ROAD-001 to FE-TP-ROAD-153
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -3346,4 +3346,82 @@ describe('useTripPlanner road trip: a booked night at the edge of a day', () =>
act(() => { result.current.toggleRoadtripDay(5) })
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([])
})
it('FE-TP-ROAD-151: a road driven out of the hotel and again between two places takes a via on the second pass', async () => {
// Out of the hotel east to the lookout, then back west past the hotel to the falls:
// the road between the hotel and the lookout is driven twice, and only the second
// pass is a leg a via can be filed on.
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: 10 }),
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 11, name: 'Lookout' },
{ assignmentId: 12, ownerIndex: 1, lat: 53, lng: 9, name: 'Falls' },
],
geometry: [[53, 10], [53, 11], [53, 10], [53, 9]],
}]
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53, 10.5) })
expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53, 10.5)
expect(toasts.filter(t => t.message === NO_VIA)).toEqual([])
await act(async () => { await result.current.moveRoadtripVia(5, 3, 53, 10.5) })
expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53, 10.5, 0)
})
it('FE-TP-ROAD-152: a road only the hotel’s drives use, out in the morning and back at night, still takes none', async () => {
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: 10 }),
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 11, name: 'Lookout' },
bookend('evening', { ownerIndex: 1, lat: 53, lng: 10 }),
],
geometry: [[53, 10], [53, 11], [53, 10]],
}]
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53, 10.5) })
expect(rt.vias.add).not.toHaveBeenCalled()
expect(toasts).toContainEqual({ message: NO_VIA, type: 'info' })
})
it('FE-TP-ROAD-153: where yesterday’s drive into the hotel and today’s leg share a road, the click goes to today’s leg', async () => {
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
rt.corridor.day = { dayId: 5, dayNumber: 1 }
rt.routes.days = [
{
dayId: 5,
dayNumber: 1,
stops: [
{ assignmentId: 11, ownerIndex: 0, lat: 53, lng: 9, name: 'Harbour' },
bookend('evening', { ownerIndex: 1, lat: 53, lng: 10 }),
],
geometry: [[53, 9], [53, 10]],
},
{
dayId: 6,
dayNumber: 2,
stops: [
{ assignmentId: 21, ownerIndex: 0, lat: 53, lng: 9.2, name: 'Mill' },
{ assignmentId: 22, ownerIndex: 1, lat: 53, lng: 9.8, name: 'Dunes' },
],
geometry: [[53, 9.2], [53, 9.8]],
},
]
const { result } = await renderRoadtrip()
await act(async () => { await result.current.addRoadtripVia(53, 9.5) })
expect(rt.vias.add).toHaveBeenCalledWith(6, 0, 53, 9.5)
expect(toasts.filter(t => t.message === NO_VIA)).toEqual([])
})
})
+55 -27
View File
@@ -46,7 +46,7 @@ import { alternativesBusy, buildAlternativeOverlays } from '../../components/Roa
import { pinAlternative, railDriveOn, railLegAt, refusalHint, type PinProof } from '../../components/Roadtrip/alternativePins'
import { stopArrival } from '../../components/Roadtrip/stopArrival'
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor'
import { projectOntoRoute, sliceAtMeters, type CorridorHit, type LatLng } from '../../components/Roadtrip/corridor'
import {
insertIndexForAlong,
reanchorAfterInsert,
@@ -1758,32 +1758,25 @@ export function useTripPlanner() {
// 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: {
type Anchor = {
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
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card
// measured the new position against a line that no longer covers those stops — a
// point dragged near Brandenburg was projected onto the short remainder of card 1
// and came back anchored to its last stop, which put the via on the night drive
// itself and pushed the stop before it over midnight.
//
// So every card is measured, and the answer is filtered by the day the ANCHOR is
// stored on. Same promise, kept against the stops rather than against the card.
if (day.geometry.length < 2) continue
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
const hit = projectOntoRoute({ lat, lng }, spine)
if (!hit) continue
if (best && hit.offRouteKm >= best.offRouteKm) continue
// Which stop the via follows: the last one the car passes before reaching it.
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
}
// A road the day drives twice, out of the hotel in the morning and past it again later,
// is on the line twice, and the closer pass wins by metres at most. A pass that can
// take a via beats one on the hotel's drive, which files nothing, by this much.
const SAME_ROAD_KM = 0.03
const beats = (c: Anchor, b: Anchor | null) => {
if (!b) return true
if (c.bookendLeg === b.bookendLeg) return c.offRouteKm < b.offRouteKm
return c.bookendLeg ? c.offRouteKm + SAME_ROAD_KM < b.offRouteKm : c.offRouteKm <= b.offRouteKm + SAME_ROAD_KM
}
// Which stop of this card the point falls behind, for a hit measured on its line.
const readAnchor = (day: (typeof roadtripRoutes.days)[number], hit: CorridorHit, stopsAlong: number[]): Anchor | null => {
// Before the card's first stop means a drive that arrives here but leaves from a
// stop on the card BEFORE this one: the incoming night drive (`nightSpill.ts`), or
// on a trip with connected days the drive from where yesterday ended, which is drawn
@@ -1800,8 +1793,8 @@ export function useTripPlanner() {
const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
best = {
if (onlyDayId !== undefined && owner !== onlyDayId) return null
return {
dayId: owner,
afterIndex: arrivedFrom.ownerIndex ?? 0,
offRouteKm: hit.offRouteKm,
@@ -1809,7 +1802,6 @@ export function useTripPlanner() {
bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend,
card: { dayId: day.dayId, index: -1 },
}
continue
}
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// Named by the day the anchor stop is STORED on and its position there, not by the
@@ -1818,14 +1810,14 @@ export function useTripPlanner() {
// night drive — and a via filed under the card's numbers matches no stop when the
// route is next built, which reads as a drag that did nothing at all.
const anchor = day.stops[at]
if (!anchor) continue
if (!anchor) return null
// Falling back to the card's own numbers is not a guard against a bug, it is the
// meaning: a stop that names no other day IS stored on the card it is drawn on,
// which is every stop on a trip that never drives past midnight.
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 = {
if (onlyDayId !== undefined && owner !== onlyDayId) return null
return {
dayId: owner,
afterIndex: anchor.ownerIndex ?? at,
offRouteKm: hit.offRouteKm,
@@ -1834,6 +1826,42 @@ export function useTripPlanner() {
card: { dayId: day.dayId, index: at },
}
}
let best: Anchor | 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
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card
// measured the new position against a line that no longer covers those stops — a
// point dragged near Brandenburg was projected onto the short remainder of card 1
// and came back anchored to its last stop, which put the via on the night drive
// itself and pushed the stop before it over midnight.
//
// So every card is measured, and the answer is filtered by the day the ANCHOR is
// stored on. Same promise, kept against the stops rather than against the card.
if (day.geometry.length < 2) continue
const spine = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln }))
const hit = projectOntoRoute({ lat, lng }, spine)
if (!hit) continue
if (best && hit.offRouteKm >= best.offRouteKm + SAME_ROAD_KM) continue
// Which stop the via follows: the last one the car passes before reaching it.
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
let candidate = readAnchor(day, hit, stopsAlong)
if (candidate?.bookendLeg) {
// Landed on the drive to or from the hotel, a road the day can drive again between
// two of its own stops. Asked once more of the stretch between the day's first and
// last stop, and taken when that answer lies on the same road.
const first = day.stops.findIndex(stop => !stop.bookend)
const last = day.stops.length - 1 - [...day.stops].reverse().findIndex(stop => !stop.bookend)
const fromKm = stopsAlong[first] ?? 0
const toKm = stopsAlong[last] ?? 0
const inner = first >= 0 && first < last && fromKm < toKm
? projectOntoRoute({ lat, lng }, spine, { fromKm, toKm })
: null
const retry = inner && inner.offRouteKm <= hit.offRouteKm + SAME_ROAD_KM ? readAnchor(day, inner, stopsAlong) : null
if (retry && !retry.bookendLeg) candidate = retry
}
if (candidate && beats(candidate, best)) best = candidate
}
return best
}, [roadtripRoutes.days])
@@ -9,7 +9,7 @@ import type { Reservation, TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
// FE-MOB-RTROW-001 to FE-MOB-RTROW-058
// FE-MOB-RTROW-001 to FE-MOB-RTROW-059
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -842,4 +842,15 @@ describe('RtBookendRow', () => {
expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.getByText('7:10 PM')).toBeInTheDocument()
})
it('FE-MOB-RTROW-059: a check-out morning the drive leaves after the room is handed back is marked', () => {
const entry = (arrival: string) => ({ arrival, departure: arrival, anchored: false, dayOffset: 0 })
const late = render(<RtBookendRow row={row({ time: '12:27', entry: entry('12:27') })} bookend={reading()} chrome={chrome} onOpen={vi.fn()} />)
expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
expect(screen.getByText('roadtrip.bookend.afterCheckOut')).toBeInTheDocument()
late.unmount()
render(<RtBookendRow row={row({ entry: entry('08:40') })} bookend={reading()} chrome={chrome} onOpen={vi.fn()} />)
expect(screen.queryByText('roadtrip.bookend.afterCheckOut')).toBeNull()
})
})