diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
index 57604e71e..131137163 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
@@ -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()
+ expect(screen.getByText('until 10:00')).toBeInTheDocument()
+ expect(screen.getByText('Leaves after check-out')).toBeInTheDocument()
+ unmount()
+
+ wrap()
+ 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(
,
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index dbfc7401a..ca1d99f85 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -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 (
@@ -887,6 +888,12 @@ function BookendStop({ reading, entry, late, driveFindings, continues, starts, o
{meta || badges ? (
{meta ? {meta} : null}
+ {afterCheckOut ? (
+
+
+ {t('roadtrip.bookend.afterCheckOut')}
+
+ ) : null}
{driveFindings.map(w => )}
{late.map(w => )}
diff --git a/client/src/components/Roadtrip/nightBookend.test.ts b/client/src/components/Roadtrip/nightBookend.test.ts
index d4eb78c96..8f3c65ef3 100644
--- a/client/src/components/Roadtrip/nightBookend.test.ts
+++ b/client/src/components/Roadtrip/nightBookend.test.ts
@@ -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 => ({
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.
diff --git a/client/src/components/Roadtrip/nightBookend.ts b/client/src/components/Roadtrip/nightBookend.ts
index 37cc2c604..0e483a286 100644
--- a/client/src/components/Roadtrip/nightBookend.ts
+++ b/client/src/components/Roadtrip/nightBookend.ts
@@ -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 | 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
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
index e891624e4..3120bde47 100644
--- a/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx
@@ -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 (
@@ -216,9 +217,10 @@ export function RtBookendRow({ row, bookend, chrome, onOpen }: {
{bookendTitle(bookend, t)}
- {(meta || row.warning) && (
+ {(meta || afterCheckOut || row.warning) && (
{meta && {meta}}
+ {afterCheckOut && }>{t('roadtrip.bookend.afterCheckOut')}}
{row.warning && warningMark(row.warning, chrome)}
)}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
index d4cc13e34..403e32e3c 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
+++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
@@ -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([])
+ })
})
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 4534a56ac..f649631de 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -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])
diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
index 2c2fcedbd..a417e8d22 100644
--- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
+++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
@@ -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()
+ expect(screen.getByText('roadtrip.stay.until:10:00')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.bookend.afterCheckOut')).toBeInTheDocument()
+ late.unmount()
+
+ render()
+ expect(screen.queryByText('roadtrip.bookend.afterCheckOut')).toBeNull()
+ })
})
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index 3ee03856a..79c26eb35 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'من {name}',
'roadtrip.bookend.back': 'العودة إلى {name}',
'roadtrip.bookend.checkIn': 'تسجيل الوصول · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'الانطلاق بعد موعد تسجيل المغادرة',
'roadtrip.bookend.noVia': 'لا يمكن وضع نقطة عبور على الطريق من مكان إقامتك أو إليه. أضف توقفاً هناك بدلاً من ذلك.',
};
export default roadtrip;
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index eb21bb36e..a2b3bac25 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Saindo de {name}',
'roadtrip.bookend.back': 'Volta para {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Saída depois do check-out',
'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.',
};
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index bb9ea0532..690a4da6d 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Des de {name}',
'roadtrip.bookend.back': 'Tornada a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Marxa després de l’hora de sortida',
'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.',
};
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 58a7a0481..cabe54e0b 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Z ubytování {name}',
'roadtrip.bookend.back': 'Zpět do ubytování {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Odjezd až po check-outu',
'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.',
};
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index 366daf411..2129de69a 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Ab {name}',
'roadtrip.bookend.back': 'Zurück zu {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Abfahrt nach dem Check-out',
'roadtrip.bookend.noVia':
'Auf der Fahrt zur oder von der Unterkunft geht kein Zwischenpunkt. Füge dort stattdessen einen Stopp hinzu.',
};
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 5cfca4827..3e4161945 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -290,6 +290,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'From {name}',
'roadtrip.bookend.back': 'Back to {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.',
};
export default roadtrip;
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 55dccf04c..7a2819cec 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Desde {name}',
'roadtrip.bookend.back': 'Vuelta a {name}',
'roadtrip.bookend.checkIn': 'Entrada · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Parte después de la hora de salida',
'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í.',
};
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 1c4f3f3e9..11d967a5e 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -299,6 +299,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Depuis {name}',
'roadtrip.bookend.back': 'Retour à {name}',
'roadtrip.bookend.checkIn': 'Arrivée · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Part après l’heure limite de départ',
'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.',
};
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index deb6e2ae2..4cfafc6c4 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -301,6 +301,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Από {name}',
'roadtrip.bookend.back': 'Επιστροφή: {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Αναχώρηση μετά το check-out',
'roadtrip.bookend.noVia':
'Δεν μπαίνει ενδιάμεσο σημείο στη διαδρομή προς ή από το κατάλυμα. Προσθέστε εκεί μια στάση.',
};
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 5b334f6d7..19874898f 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Indulás: {name}',
'roadtrip.bookend.back': 'Vissza: {name}',
'roadtrip.bookend.checkIn': 'Bejelentkezés · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Indulás kijelentkezés után',
'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.',
};
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index ad90c1ac2..776715171 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Dari {name}',
'roadtrip.bookend.back': 'Kembali ke {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Berangkat setelah check-out',
'roadtrip.bookend.noVia':
'Titik lintas tidak bisa ditaruh di perjalanan ke atau dari penginapan Anda. Tambahkan perhentian di sana.',
};
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index 87fdfc960..92b1bb559 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Da {name}',
'roadtrip.bookend.back': 'Ritorno a {name}',
'roadtrip.bookend.checkIn': 'Check-in · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Partenza dopo il check-out',
'roadtrip.bookend.noVia':
'Nessun punto di passaggio sul tragitto verso o dal tuo alloggio. Aggiungi invece una sosta in quel punto.',
};
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index 668331d4b..73c2ee04b 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -292,6 +292,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}から',
'roadtrip.bookend.back': '{name}へ戻る',
'roadtrip.bookend.checkIn': 'チェックイン · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'チェックアウト後に出発',
'roadtrip.bookend.noVia': '宿泊先との間の区間には経由地を置けません。代わりにそこへ立ち寄り先を追加してください。',
};
export default roadtrip;
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index 8177fdf5a..fa188f1b5 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -289,6 +289,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '{name}에서 출발',
'roadtrip.bookend.back': '{name}(으)로 돌아가기',
'roadtrip.bookend.checkIn': '체크인 · {name}',
+ 'roadtrip.bookend.afterCheckOut': '체크아웃 후 출발',
'roadtrip.bookend.noVia': '숙소를 오가는 구간에는 경유지를 둘 수 없습니다. 대신 그곳에 정차 지점을 추가하세요.',
};
export default roadtrip;
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 52e7d973d..f9caad8ba 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -297,6 +297,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Vanaf {name}',
'roadtrip.bookend.back': 'Terug naar {name}',
'roadtrip.bookend.checkIn': 'Inchecken · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Vertrek na het uitchecken',
'roadtrip.bookend.noVia':
'Geen tussenpunt op de rit naar of van je verblijf. Voeg daar in plaats daarvan een stop toe.',
};
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index 9af6a8293..16fbd684a 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Wyjazd: {name}',
'roadtrip.bookend.back': 'Powrót: {name}',
'roadtrip.bookend.checkIn': 'Zameldowanie · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Wyjazd po godzinie wymeldowania',
'roadtrip.bookend.noVia':
'Na trasie do noclegu ani z niego nie można dodać punktu pośredniego. Dodaj tam zamiast tego postój.',
};
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index 63704f50c..a6c3b61bf 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -298,6 +298,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Отправление: {name}',
'roadtrip.bookend.back': 'Возвращение: {name}',
'roadtrip.bookend.checkIn': 'Заезд · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Отъезд позже расчётного часа',
'roadtrip.bookend.noVia':
'На маршрут до места проживания или от него нельзя поставить промежуточную точку. Добавьте там остановку.',
};
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 81782ad13..8177d1e7e 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -293,6 +293,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Från {name}',
'roadtrip.bookend.back': 'Tillbaka till {name}',
'roadtrip.bookend.checkIn': 'Incheckning · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Avresa efter utcheckning',
'roadtrip.bookend.noVia':
'Ingen mellanpunkt på resan till eller från ditt boende. Lägg till ett stopp där i stället.',
};
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index c0144781e..459aa92c4 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -296,6 +296,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Başlangıç: {name}',
'roadtrip.bookend.back': 'Dönüş: {name}',
'roadtrip.bookend.checkIn': 'Giriş · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Çıkış saatinden sonra hareket',
'roadtrip.bookend.noVia':
'Konaklama yerinize giden ya da oradan çıkan yola ara nokta konamaz. Bunun yerine oraya bir mola ekleyin.',
};
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 992eef616..5f1b1f204 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -295,6 +295,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Відправлення: {name}',
'roadtrip.bookend.back': 'Повернення: {name}',
'roadtrip.bookend.checkIn': 'Заїзд · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Від’їзд пізніше розрахункової години',
'roadtrip.bookend.noVia':
'На маршрут до місця проживання чи від нього не можна поставити проміжну точку. Додайте там зупинку.',
};
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index d0c023101..f843ed6f7 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -294,6 +294,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': 'Từ {name}',
'roadtrip.bookend.back': 'Về lại {name}',
'roadtrip.bookend.checkIn': 'Nhận phòng · {name}',
+ 'roadtrip.bookend.afterCheckOut': 'Khởi hành sau giờ trả phòng',
'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 ở đó.',
};
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 518df0b09..e3ca1e8da 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -278,6 +278,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '從 {name} 出發',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
+ 'roadtrip.bookend.afterCheckOut': '退房後才出發',
'roadtrip.bookend.noVia': '往返住宿地點的路段無法加入途經點。請改在那裡新增一個停靠點。',
};
export default roadtrip;
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 438418709..9a77567ef 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -279,6 +279,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.bookend.from': '从 {name} 出发',
'roadtrip.bookend.back': '返回 {name}',
'roadtrip.bookend.checkIn': '入住 · {name}',
+ 'roadtrip.bookend.afterCheckOut': '退房后才出发',
'roadtrip.bookend.noVia': '往返住处的路段不能添加途经点。请改为在那里添加一个停靠点。',
};
export default roadtrip;
diff --git a/shared/src/roadtrip/corridor.spec.ts b/shared/src/roadtrip/corridor.spec.ts
index c40b0f307..f98386400 100644
--- a/shared/src/roadtrip/corridor.spec.ts
+++ b/shared/src/roadtrip/corridor.spec.ts
@@ -89,6 +89,45 @@ describe('projectOntoRoute', () => {
expect(projectOntoRoute(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', () => {
diff --git a/shared/src/roadtrip/corridor.ts b/shared/src/roadtrip/corridor.ts
index f44bc2616..605bc89b1 100644
--- a/shared/src/roadtrip/corridor.ts
+++ b/shared/src/roadtrip/corridor.ts
@@ -59,22 +59,50 @@ export interface CorridorHit {
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;
let best = Number.POSITIVE_INFINITY;
let bestAlong = 0;
let travelled = 0;
+ let measured = false;
for (let i = 0; i < line.length - 1; i++) {
const a = line[i]!;
const b = line[i + 1]!;
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) {
best = d;
- bestAlong = travelled + t * segment;
+ bestAlong = start + t * segment;
}
- travelled += segment;
}
+ if (within && !measured) return null;
return { offRouteKm: best, alongKm: bestAlong };
}
diff --git a/wiki/Road-Trip.md b/wiki/Road-Trip.md
index 8711c4528..8aaa9e3de 100644
--- a/wiki/Road-Trip.md
+++ b/wiki/Road-Trip.md
@@ -293,13 +293,13 @@ Normally a day drives from its first stop to its last, and the hotel you slept i
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.
- 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.
- 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.
-- 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.
Switch it off and every day drives from its first stop to its last, as before. The **Days** view does not change either way.