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() 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', () => { 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( const { container } = wrap(
<RoadtripSidebar routes={routes({ days: [loop()] })} onReorderStop={vi.fn()} onEditStay={vi.fn()} onSetStopKind={vi.fn()} onSetStopFill={vi.fn()} />, <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 { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds' import { STOP_KIND_BY_KEY } from './stopKinds'
import { arrivingReroutable, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' 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 { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker' import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker' import StopFillPicker from './StopFillPicker'
@@ -876,7 +876,8 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
const { t } = useTranslation() const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const meta = bookendMeta(reading, t, is12h) 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 ( return (
<DiscRow Icon={BOOKEND_ICON} starts={starts} continues={continues} onOpen={onOpen}> <DiscRow Icon={BOOKEND_ICON} starts={starts} continues={continues} onOpen={onOpen}>
<span className="flex min-w-0 items-start gap-2 rounded-lg px-1.5 pb-1 pt-0.5 transition-colors group-hover:bg-surface-hover"> <span className="flex min-w-0 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 ? ( {meta || badges ? (
<span className="flex flex-wrap items-center gap-1"> <span className="flex flex-wrap items-center gap-1">
{meta ? <span className="text-content-muted" style={{ fontSize: FS.meta }}>{meta}</span> : null} {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} />)} {driveFindings.map(w => <DriveFindingBadge key={w.code} warning={w} />)}
{late.map(w => <LateBadge key={w.code} late={w} />)} {late.map(w => <LateBadge key={w.code} late={w} />)}
</span> </span>
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { BedDouble } from 'lucide-react' 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' 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 => ({ const reading = (over: Partial<BookendReading> = {}): BookendReading => ({
phase: 'morning', 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() 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', () => { it('FE-BOOKEND-004: a tap opens the booking behind the night for somebody who may edit bookings', () => {
expect(bookendBooking(reading(), true)).toBe(41) expect(bookendBooking(reading(), true)).toBe(41)
// The editor is the only view a hotel booking has, so anybody else gets the place. // 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 { BedDouble, type LucideIcon } from 'lucide-react'
import { formatClockTime } from '../../utils/formatters' import { formatClockTime } from '../../utils/formatters'
import { parseClock, type ScheduleEntry } from './roadtripModel'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import type { BookendReading } from './roadtripRowModel' 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 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. */ /** The fields of a trip place the hotel of a stay is read from. */
export interface HotelPlace { export interface HotelPlace {
id: number id: number
@@ -4,7 +4,7 @@ import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn' import MIconBtn from '../../../components/MIconBtn'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel' import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { bookingOpens, carrierIcon, rideText, terminalLine } from '../../../../components/Roadtrip/carrierRide' 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 { bookingClock, bookingIcon } from '../../../../components/Roadtrip/stopBookings'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds' import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units' import { formatDistance } from '../../../../utils/units'
@@ -207,6 +207,7 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
}) { }) {
const { t } = chrome const { t } = chrome
const meta = bookendMeta(bookend, t, chrome.is12h) const meta = bookendMeta(bookend, t, chrome.is12h)
const afterCheckOut = leavesAfterCheckOut(bookend, row.entry)
return ( return (
<TapRow onOpen={onOpen}> <TapRow onOpen={onOpen}>
<span className="flex justify-center"> <span className="flex justify-center">
@@ -216,9 +217,10 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
</span> </span>
<span className="min-w-0 py-2"> <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> <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]"> <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>} {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)} {row.warning && warningMark(row.warning, chrome)}
</span> </span>
)} )}
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' 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 React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react' import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext' 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) }) act(() => { result.current.toggleRoadtripDay(5) })
expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([]) 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 { pinAlternative, railDriveOn, railLegAt, refusalHint, type PinProof } from '../../components/Roadtrip/alternativePins'
import { stopArrival } from '../../components/Roadtrip/stopArrival' import { stopArrival } from '../../components/Roadtrip/stopArrival'
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' 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 { import {
insertIndexForAlong, insertIndexForAlong,
reanchorAfterInsert, reanchorAfterInsert,
@@ -1758,32 +1758,25 @@ export function useTripPlanner() {
// the evening's drive is reached from the index that shapes the road into tomorrow. // 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 // `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. // of the stop before it there, -1 for the drive in before the card's first stop.
let best: { type Anchor = {
dayId: number dayId: number
afterIndex: number afterIndex: number
offRouteKm: number offRouteKm: number
terminal: CarrierTerminal['role'] | null terminal: CarrierTerminal['role'] | null
bookendLeg: boolean bookendLeg: boolean
card: { dayId: number; index: number } card: { dayId: number; index: number }
} | null = null }
for (const day of roadtripRoutes.days) { // A road the day drives twice, out of the hotel in the morning and past it again later,
// NOT `day.dayId !== onlyDayId`. A dragged via has to stay on the day it is stored // is on the line twice, and the closer pass wins by metres at most. A pass that can
// on, but that day's stops are no longer all on the card of the same name: after a // take a via beats one on the hotel's drive, which files nothing, by this much.
// night drive they are drawn on the next one (`nightSpill.ts`). Filtering by card const SAME_ROAD_KM = 0.03
// measured the new position against a line that no longer covers those stops — a const beats = (c: Anchor, b: Anchor | null) => {
// point dragged near Brandenburg was projected onto the short remainder of card 1 if (!b) return true
// and came back anchored to its last stop, which put the via on the night drive if (c.bookendLeg === b.bookendLeg) return c.offRouteKm < b.offRouteKm
// itself and pushed the stop before it over midnight. return c.bookendLeg ? c.offRouteKm + SAME_ROAD_KM < b.offRouteKm : c.offRouteKm <= b.offRouteKm + SAME_ROAD_KM
// }
// So every card is measured, and the answer is filtered by the day the ANCHOR is // Which stop of this card the point falls behind, for a hit measured on its line.
// stored on. Same promise, kept against the stops rather than against the card. const readAnchor = (day: (typeof roadtripRoutes.days)[number], hit: CorridorHit, stopsAlong: number[]): Anchor | null => {
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)
// Before the card's first stop means a drive that arrives here but leaves from a // 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 // 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 // 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 const arrivedFrom = day.spills?.find(sp => sp.at === 0)?.fromStop ?? day.arrivingFrom
if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) { if (arrivedFrom && hit.alongKm < (stopsAlong[0] ?? 0)) {
const owner = arrivedFrom.ownerDayId ?? day.dayId const owner = arrivedFrom.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue if (onlyDayId !== undefined && owner !== onlyDayId) return null
best = { return {
dayId: owner, dayId: owner,
afterIndex: arrivedFrom.ownerIndex ?? 0, afterIndex: arrivedFrom.ownerIndex ?? 0,
offRouteKm: hit.offRouteKm, offRouteKm: hit.offRouteKm,
@@ -1809,7 +1802,6 @@ export function useTripPlanner() {
bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend, bookendLeg: !!arrivedFrom.bookend || !!day.stops[0]?.bookend,
card: { dayId: day.dayId, index: -1 }, card: { dayId: day.dayId, index: -1 },
} }
continue
} }
const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1 const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// Named by the day the anchor stop is STORED on and its position there, not by the // 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 // 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. // route is next built, which reads as a drag that did nothing at all.
const anchor = day.stops[at] 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 // 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, // 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. // which is every stop on a trip that never drives past midnight.
const owner = anchor.ownerDayId ?? day.dayId const owner = anchor.ownerDayId ?? day.dayId
// A drag stays on its own day; a fresh click may land wherever it landed. // A drag stays on its own day; a fresh click may land wherever it landed.
if (onlyDayId !== undefined && owner !== onlyDayId) continue if (onlyDayId !== undefined && owner !== onlyDayId) return null
best = { return {
dayId: owner, dayId: owner,
afterIndex: anchor.ownerIndex ?? at, afterIndex: anchor.ownerIndex ?? at,
offRouteKm: hit.offRouteKm, offRouteKm: hit.offRouteKm,
@@ -1834,6 +1826,42 @@ export function useTripPlanner() {
card: { dayId: day.dayId, index: at }, 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 return best
}, [roadtripRoutes.days]) }, [roadtripRoutes.days])
@@ -9,7 +9,7 @@ import type { Reservation, TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch' import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion' 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. // Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) => const t: TranslationFn = (key, params) =>
@@ -842,4 +842,15 @@ describe('RtBookendRow', () => {
expect(screen.getByText('30 min')).toBeInTheDocument() expect(screen.getByText('30 min')).toBeInTheDocument()
expect(screen.getByText('7:10 PM')).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()
})
}) })
+1
View File
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'من {name}', 'roadtrip.bookend.from': 'من {name}',
'roadtrip.bookend.back': 'العودة إلى {name}', 'roadtrip.bookend.back': 'العودة إلى {name}',
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}', 'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
'roadtrip.bookend.afterCheckOut': 'الانطلاق بعد موعد تسجيل المغادرة',
'roadtrip.bookend.noVia': 'لا يمكن وضع نقطة عبور على الطريق من مكان إقامتك أو إليه. أضف توقفاً هناك بدلاً من ذلك.', 'roadtrip.bookend.noVia': 'لا يمكن وضع نقطة عبور على الطريق من مكان إقامتك أو إليه. أضف توقفاً هناك بدلاً من ذلك.',
}; };
export default roadtrip; export default roadtrip;
+1
View File
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Saindo de {name}', 'roadtrip.bookend.from': 'Saindo de {name}',
'roadtrip.bookend.back': 'Volta para {name}', 'roadtrip.bookend.back': 'Volta para {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Saída depois do check-out',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Não dá para colocar um ponto de passagem no trajeto de ida ou volta da sua hospedagem. Adicione uma parada ali.', 'Não dá para colocar um ponto de passagem no trajeto de ida ou volta da sua hospedagem. Adicione uma parada ali.',
}; };
+1
View File
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Des de {name}', 'roadtrip.bookend.from': 'Des de {name}',
'roadtrip.bookend.back': 'Tornada a {name}', 'roadtrip.bookend.back': 'Tornada a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}', 'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.afterCheckOut': 'Marxa després de l’hora de sortida',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'No es pot posar cap punt de pas al trajecte fins al teu allotjament o des d’ell. Afegeix-hi una parada.', 'No es pot posar cap punt de pas al trajecte fins al teu allotjament o des d’ell. Afegeix-hi una parada.',
}; };
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Z ubytování {name}', 'roadtrip.bookend.from': 'Z ubytování {name}',
'roadtrip.bookend.back': 'Zpět do ubytování {name}', 'roadtrip.bookend.back': 'Zpět do ubytování {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Odjezd až po check-outu',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Na trasu do ubytování ani z něj nelze přidat průjezdní bod. Přidejte tam místo toho zastávku.', 'Na trasu do ubytování ani z něj nelze přidat průjezdní bod. Přidejte tam místo toho zastávku.',
}; };
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Ab {name}', 'roadtrip.bookend.from': 'Ab {name}',
'roadtrip.bookend.back': 'Zurück zu {name}', 'roadtrip.bookend.back': 'Zurück zu {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Abfahrt nach dem Check-out',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Auf der Fahrt zur oder von der Unterkunft geht kein Zwischenpunkt. Füge dort stattdessen einen Stopp hinzu.', 'Auf der Fahrt zur oder von der Unterkunft geht kein Zwischenpunkt. Füge dort stattdessen einen Stopp hinzu.',
}; };
+1
View File
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'From {name}', 'roadtrip.bookend.from': 'From {name}',
'roadtrip.bookend.back': 'Back to {name}', 'roadtrip.bookend.back': 'Back to {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Leaves after check-out',
'roadtrip.bookend.noVia': 'No via point on the drive to or from your stay. Add a stop there instead.', 'roadtrip.bookend.noVia': 'No via point on the drive to or from your stay. Add a stop there instead.',
}; };
export default roadtrip; export default roadtrip;
+1
View File
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Desde {name}', 'roadtrip.bookend.from': 'Desde {name}',
'roadtrip.bookend.back': 'Vuelta a {name}', 'roadtrip.bookend.back': 'Vuelta a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}', 'roadtrip.bookend.checkIn': 'Entrada · {name}',
'roadtrip.bookend.afterCheckOut': 'Parte después de la hora de salida',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'No se puede poner un punto de paso en el trayecto hacia tu alojamiento o desde él. Añade una parada allí.', 'No se puede poner un punto de paso en el trayecto hacia tu alojamiento o desde él. Añade una parada allí.',
}; };
+1
View File
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Depuis {name}', 'roadtrip.bookend.from': 'Depuis {name}',
'roadtrip.bookend.back': 'Retour à {name}', 'roadtrip.bookend.back': 'Retour à {name}',
'roadtrip.bookend.checkIn': 'Arrivée · {name}', 'roadtrip.bookend.checkIn': 'Arrivée · {name}',
'roadtrip.bookend.afterCheckOut': 'Part après l’heure limite de départ',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Pas de point de passage sur le trajet vers ou depuis votre hébergement. Ajoutez plutôt un arrêt à cet endroit.', 'Pas de point de passage sur le trajet vers ou depuis votre hébergement. Ajoutez plutôt un arrêt à cet endroit.',
}; };
+1
View File
@@ -301,6 +301,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Από {name}', 'roadtrip.bookend.from': 'Από {name}',
'roadtrip.bookend.back': 'Επιστροφή: {name}', 'roadtrip.bookend.back': 'Επιστροφή: {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Αναχώρηση μετά το check-out',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Δεν μπαίνει ενδιάμεσο σημείο στη διαδρομή προς ή από το κατάλυμα. Προσθέστε εκεί μια στάση.', 'Δεν μπαίνει ενδιάμεσο σημείο στη διαδρομή προς ή από το κατάλυμα. Προσθέστε εκεί μια στάση.',
}; };
+1
View File
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Indulás: {name}', 'roadtrip.bookend.from': 'Indulás: {name}',
'roadtrip.bookend.back': 'Vissza: {name}', 'roadtrip.bookend.back': 'Vissza: {name}',
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}', 'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
'roadtrip.bookend.afterCheckOut': 'Indulás kijelentkezés után',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'A szálláshoz vezető vagy onnan induló útra nem kerülhet köztes pont. Adj hozzá inkább egy megállót ott.', 'A szálláshoz vezető vagy onnan induló útra nem kerülhet köztes pont. Adj hozzá inkább egy megállót ott.',
}; };
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Dari {name}', 'roadtrip.bookend.from': 'Dari {name}',
'roadtrip.bookend.back': 'Kembali ke {name}', 'roadtrip.bookend.back': 'Kembali ke {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Berangkat setelah check-out',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Titik lintas tidak bisa ditaruh di perjalanan ke atau dari penginapan Anda. Tambahkan perhentian di sana.', 'Titik lintas tidak bisa ditaruh di perjalanan ke atau dari penginapan Anda. Tambahkan perhentian di sana.',
}; };
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Da {name}', 'roadtrip.bookend.from': 'Da {name}',
'roadtrip.bookend.back': 'Ritorno a {name}', 'roadtrip.bookend.back': 'Ritorno a {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}', 'roadtrip.bookend.checkIn': 'Check-in · {name}',
'roadtrip.bookend.afterCheckOut': 'Partenza dopo il check-out',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Nessun punto di passaggio sul tragitto verso o dal tuo alloggio. Aggiungi invece una sosta in quel punto.', 'Nessun punto di passaggio sul tragitto verso o dal tuo alloggio. Aggiungi invece una sosta in quel punto.',
}; };
+1
View File
@@ -292,6 +292,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}から', 'roadtrip.bookend.from': '{name}から',
'roadtrip.bookend.back': '{name}へ戻る', 'roadtrip.bookend.back': '{name}へ戻る',
'roadtrip.bookend.checkIn': 'チェックイン · {name}', 'roadtrip.bookend.checkIn': 'チェックイン · {name}',
'roadtrip.bookend.afterCheckOut': 'チェックアウト後に出発',
'roadtrip.bookend.noVia': '宿泊先との間の区間には経由地を置けません。代わりにそこへ立ち寄り先を追加してください。', 'roadtrip.bookend.noVia': '宿泊先との間の区間には経由地を置けません。代わりにそこへ立ち寄り先を追加してください。',
}; };
export default roadtrip; export default roadtrip;
+1
View File
@@ -289,6 +289,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}에서 출발', 'roadtrip.bookend.from': '{name}에서 출발',
'roadtrip.bookend.back': '{name}(으)로 돌아가기', 'roadtrip.bookend.back': '{name}(으)로 돌아가기',
'roadtrip.bookend.checkIn': '체크인 · {name}', 'roadtrip.bookend.checkIn': '체크인 · {name}',
'roadtrip.bookend.afterCheckOut': '체크아웃 후 출발',
'roadtrip.bookend.noVia': '숙소를 오가는 구간에는 경유지를 둘 수 없습니다. 대신 그곳에 정차 지점을 추가하세요.', 'roadtrip.bookend.noVia': '숙소를 오가는 구간에는 경유지를 둘 수 없습니다. 대신 그곳에 정차 지점을 추가하세요.',
}; };
export default roadtrip; export default roadtrip;
+1
View File
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Vanaf {name}', 'roadtrip.bookend.from': 'Vanaf {name}',
'roadtrip.bookend.back': 'Terug naar {name}', 'roadtrip.bookend.back': 'Terug naar {name}',
'roadtrip.bookend.checkIn': 'Inchecken · {name}', 'roadtrip.bookend.checkIn': 'Inchecken · {name}',
'roadtrip.bookend.afterCheckOut': 'Vertrek na het uitchecken',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Geen tussenpunt op de rit naar of van je verblijf. Voeg daar in plaats daarvan een stop toe.', 'Geen tussenpunt op de rit naar of van je verblijf. Voeg daar in plaats daarvan een stop toe.',
}; };
+1
View File
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Wyjazd: {name}', 'roadtrip.bookend.from': 'Wyjazd: {name}',
'roadtrip.bookend.back': 'Powrót: {name}', 'roadtrip.bookend.back': 'Powrót: {name}',
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}', 'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
'roadtrip.bookend.afterCheckOut': 'Wyjazd po godzinie wymeldowania',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Na trasie do noclegu ani z niego nie można dodać punktu pośredniego. Dodaj tam zamiast tego postój.', 'Na trasie do noclegu ani z niego nie można dodać punktu pośredniego. Dodaj tam zamiast tego postój.',
}; };
+1
View File
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Отправление: {name}', 'roadtrip.bookend.from': 'Отправление: {name}',
'roadtrip.bookend.back': 'Возвращение: {name}', 'roadtrip.bookend.back': 'Возвращение: {name}',
'roadtrip.bookend.checkIn': 'Заезд · {name}', 'roadtrip.bookend.checkIn': 'Заезд · {name}',
'roadtrip.bookend.afterCheckOut': 'Отъезд позже расчётного часа',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'На маршрут до места проживания или от него нельзя поставить промежуточную точку. Добавьте там остановку.', 'На маршрут до места проживания или от него нельзя поставить промежуточную точку. Добавьте там остановку.',
}; };
+1
View File
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Från {name}', 'roadtrip.bookend.from': 'Från {name}',
'roadtrip.bookend.back': 'Tillbaka till {name}', 'roadtrip.bookend.back': 'Tillbaka till {name}',
'roadtrip.bookend.checkIn': 'Incheckning · {name}', 'roadtrip.bookend.checkIn': 'Incheckning · {name}',
'roadtrip.bookend.afterCheckOut': 'Avresa efter utcheckning',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Ingen mellanpunkt på resan till eller från ditt boende. Lägg till ett stopp där i stället.', 'Ingen mellanpunkt på resan till eller från ditt boende. Lägg till ett stopp där i stället.',
}; };
+1
View File
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Başlangıç: {name}', 'roadtrip.bookend.from': 'Başlangıç: {name}',
'roadtrip.bookend.back': 'Dönüş: {name}', 'roadtrip.bookend.back': 'Dönüş: {name}',
'roadtrip.bookend.checkIn': 'Giriş · {name}', 'roadtrip.bookend.checkIn': 'Giriş · {name}',
'roadtrip.bookend.afterCheckOut': 'Çıkış saatinden sonra hareket',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Konaklama yerinize giden ya da oradan çıkan yola ara nokta konamaz. Bunun yerine oraya bir mola ekleyin.', 'Konaklama yerinize giden ya da oradan çıkan yola ara nokta konamaz. Bunun yerine oraya bir mola ekleyin.',
}; };
+1
View File
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Відправлення: {name}', 'roadtrip.bookend.from': 'Відправлення: {name}',
'roadtrip.bookend.back': 'Повернення: {name}', 'roadtrip.bookend.back': 'Повернення: {name}',
'roadtrip.bookend.checkIn': 'Заїзд · {name}', 'roadtrip.bookend.checkIn': 'Заїзд · {name}',
'roadtrip.bookend.afterCheckOut': 'Від’їзд пізніше розрахункової години',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'На маршрут до місця проживання чи від нього не можна поставити проміжну точку. Додайте там зупинку.', 'На маршрут до місця проживання чи від нього не можна поставити проміжну точку. Додайте там зупинку.',
}; };
+1
View File
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Từ {name}', 'roadtrip.bookend.from': 'Từ {name}',
'roadtrip.bookend.back': 'Về lại {name}', 'roadtrip.bookend.back': 'Về lại {name}',
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}', 'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
'roadtrip.bookend.afterCheckOut': 'Khởi hành sau giờ trả phòng',
'roadtrip.bookend.noVia': 'roadtrip.bookend.noVia':
'Không thể đặt điểm trung gian trên chặng đi hoặc về nơi lưu trú. Hãy thêm một điểm dừng ở đó.', 'Không thể đặt điểm trung gian trên chặng đi hoặc về nơi lưu trú. Hãy thêm một điểm dừng ở đó.',
}; };
+1
View File
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '從 {name} 出發', 'roadtrip.bookend.from': '從 {name} 出發',
'roadtrip.bookend.back': '返回 {name}', 'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}', 'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.afterCheckOut': '退房後才出發',
'roadtrip.bookend.noVia': '往返住宿地點的路段無法加入途經點。請改在那裡新增一個停靠點。', 'roadtrip.bookend.noVia': '往返住宿地點的路段無法加入途經點。請改在那裡新增一個停靠點。',
}; };
export default roadtrip; export default roadtrip;
+1
View File
@@ -279,6 +279,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '从 {name} 出发', 'roadtrip.bookend.from': '从 {name} 出发',
'roadtrip.bookend.back': '返回 {name}', 'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}', 'roadtrip.bookend.checkIn': '入住 · {name}',
'roadtrip.bookend.afterCheckOut': '退房后才出发',
'roadtrip.bookend.noVia': '往返住处的路段不能添加途经点。请改为在那里添加一个停靠点。', 'roadtrip.bookend.noVia': '往返住处的路段不能添加途经点。请改为在那里添加一个停靠点。',
}; };
export default roadtrip; export default roadtrip;
+39
View File
@@ -89,6 +89,45 @@ describe('projectOntoRoute', () => {
expect(projectOntoRoute(BERLIN, [])).toBeNull(); expect(projectOntoRoute(BERLIN, [])).toBeNull();
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull(); expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull();
}); });
describe('within a stretch of the drive', () => {
// Out along a straight road and back over it: every point of the road is on the
// line twice, once on the way out and once on the way back.
const out = { lat: 52, lng: 13 };
const tip = { lat: 52, lng: 14 };
const outAndBack = [out, tip, out];
const onRoad = { lat: 52, lng: 13.3 };
it('answers with the first pass for the whole line', () => {
const whole = projectOntoRoute(onRoad, outAndBack)!;
const oneWay = haversineKm(out, tip);
expect(whole.offRouteKm).toBeLessThan(0.01);
expect(whole.alongKm).toBeLessThan(oneWay / 2);
});
it('finds the second pass when asked for the way back', () => {
const oneWay = haversineKm(out, tip);
const back = projectOntoRoute(onRoad, outAndBack, { fromKm: oneWay, toKm: 2 * oneWay })!;
expect(back.offRouteKm).toBeLessThan(0.01);
expect(back.alongKm).toBeGreaterThan(oneWay);
expect(back.alongKm).toBeCloseTo(2 * oneWay - haversineKm(out, onRoad), 1);
});
it('measures to the edge of the stretch when the point lies beyond it', () => {
const straight = [
{ lat: 52, lng: 13 },
{ lat: 52, lng: 15 },
];
const length = haversineKm(straight[0]!, straight[1]!);
const hit = projectOntoRoute({ lat: 52, lng: 13.1 }, straight, { fromKm: length / 2, toKm: length })!;
expect(hit.alongKm).toBeCloseTo(length / 2, 3);
expect(hit.offRouteKm).toBeGreaterThan(55);
});
it('answers nothing for a stretch past the end of the line', () => {
expect(projectOntoRoute(onRoad, outAndBack, { fromKm: 10_000, toKm: 20_000 })).toBeNull();
});
});
}); });
describe('corridorTiles', () => { describe('corridorTiles', () => {
+32 -4
View File
@@ -59,22 +59,50 @@ export interface CorridorHit {
alongKm: number; alongKm: number;
} }
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null { /**
* Where `p` meets the line: how far off it is and how far into the drive the closest
* point comes.
*
* `within` keeps the answer to one stretch of the drive, in km from its start. A road
* driven twice in a day, out of a hotel and back past it later, is the same line twice
* over, and the answer for the whole line is always the first pass; a caller that needs
* the second one asks for the stretch it lies in. No part of that stretch on the line
* means no answer.
*/
export function projectOntoRoute(
p: LatLng,
line: LatLng[],
within?: { fromKm: number; toKm: number },
): CorridorHit | null {
if (line.length < 2) return null; if (line.length < 2) return null;
let best = Number.POSITIVE_INFINITY; let best = Number.POSITIVE_INFINITY;
let bestAlong = 0; let bestAlong = 0;
let travelled = 0; let travelled = 0;
let measured = false;
for (let i = 0; i < line.length - 1; i++) { for (let i = 0; i < line.length - 1; i++) {
const a = line[i]!; const a = line[i]!;
const b = line[i + 1]!; const b = line[i + 1]!;
const segment = haversineKm(a, b); const segment = haversineKm(a, b);
const { distanceKm: d, t } = projectOnSegment(p, a, b); const start = travelled;
travelled += segment;
if (within && (travelled < within.fromKm || start > within.toKm)) continue;
measured = true;
let { distanceKm: d, t } = projectOnSegment(p, a, b);
if (within && segment > 0) {
// A segment the stretch starts or ends inside counts only up to that edge.
const lo = Math.max(0, (within.fromKm - start) / segment);
const hi = Math.min(1, (within.toKm - start) / segment);
if (t < lo || t > hi) {
t = t < lo ? lo : hi;
d = haversineKm(p, { lat: a.lat + t * (b.lat - a.lat), lng: a.lng + t * (b.lng - a.lng) });
}
}
if (d < best) { if (d < best) {
best = d; best = d;
bestAlong = travelled + t * segment; bestAlong = start + t * segment;
} }
travelled += segment;
} }
if (within && !measured) return null;
return { offRouteKm: best, alongKm: bestAlong }; return { offRouteKm: best, alongKm: bestAlong };
} }
+2 -2
View File
@@ -293,13 +293,13 @@ Normally a day drives from its first stop to its last, and the hotel you slept i
With it on: With it on:
- The hotel stands at the edge of the day card as a row of its own, with a bed icon and no number. In the morning it reads *Check-out · Hotel Adler* with *until 10:00* on the day you leave, and *From Hotel Adler* on a day you come back to. In the evening it reads *Back to Hotel Adler*, or *Check-in · Hotel Adler* on the day the stay begins when the hotel's own stop is not on the card before it, for example because you removed it. - The hotel stands at the edge of the day card as a row of its own, with a bed icon and no number. In the morning it reads *Check-out · Hotel Adler* with *until 10:00* on the day you leave, and *From Hotel Adler* on a day you come back to. When the plan sets out after the check-out hour, the row adds *Leaves after check-out*. In the evening it reads *Back to Hotel Adler*, or *Check-in · Hotel Adler* on the day the stay begins when the hotel's own stop is not on the card before it, for example because you removed it.
- The drive to and from the hotel counts towards the day's distance, driving time, driving limits and range. The hotel does not count as a stop and does not refill the tank. - The drive to and from the hotel counts towards the day's distance, driving time, driving limits and range. The hotel does not count as a stop and does not refill the tank.
- These rows are not stops you planned, and nothing is stored for them: they cannot be dragged, removed or given a stay. Clicking one opens the night's booking, or the hotel's place when there is no booking to open. On the phone, a night without a booking opens the stay for somebody who may edit days. - These rows are not stops you planned, and nothing is stored for them: they cannot be dragged, removed or given a stay. Clicking one opens the night's booking, or the hotel's place when there is no booking to open. On the phone, a night without a booking opens the stay for somebody who may edit days.
- Only real nights count: a stay that checks in and out on the same day adds no row, and neither does a hotel without coordinates. Where two stays overlap, the choice is the one **Days** makes: the day ends at a stay checked in that day, otherwise at the one it started from, and it starts at a stay checked in on an earlier day, the one entered first where there are two. A drive between two different hotels is then a real drive. - Only real nights count: a stay that checks in and out on the same day adds no row, and neither does a hotel without coordinates. Where two stays overlap, the choice is the one **Days** makes: the day ends at a stay checked in that day, otherwise at the one it started from, and it starts at a stay checked in on an earlier day, the one entered first where there are two. A drive between two different hotels is then a real drive.
- A side of the day that already starts or ends at the hotel gets no row. Neither does a day that starts where a flight, train or ferry lands or at a hire car's pick-up desk, or ends at a departure terminal or where the hire car goes back. - A side of the day that already starts or ends at the hotel gets no row. Neither does a day that starts where a flight, train or ferry lands or at a hire car's pick-up desk, or ends at a departure terminal or where the hire car goes back.
- A day with one place becomes a drive from the hotel to it and back. A day between two nights in the same hotel with no place planned has no drive, and a check-out day with no places is the drive from one hotel to the next. - A day with one place becomes a drive from the hotel to it and back. A day between two nights in the same hotel with no place planned has no drive, and a check-out day with no places is the drive from one hotel to the next.
- The drive from and to the hotel keeps its road. Clicking it on the map places no via point and says *No via point on the drive to or from your stay. Add a stop there instead.*, and its drive band offers no **Other ways**. A place added from the search along that drive goes to the start or the end of the day. - The drive from and to the hotel keeps its road. Clicking it on the map places no via point and says *No via point on the drive to or from your stay. Add a stop there instead.*, and its drive band offers no **Other ways**. Where the day drives the same road again between two of its places, a click there places the via point on that leg as usual. A place added from the search along that drive goes to the start or the end of the day.
- With **Connect the days** on or daily travel times set, a night at one hotel is no drive between the two days. A via point on the drive from one day into the next is kept, but not used while that day ends at a hotel. - With **Connect the days** on or daily travel times set, a night at one hotel is no drive between the two days. A via point on the drive from one day into the next is kept, but not used while that day ends at a hotel.
Switch it off and every day drives from its first stop to its last, as before. The **Days** view does not change either way. Switch it off and every day drives from its first stop to its last, as before. The **Days** view does not change either way.