mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user