From 05f533dd0df8733ec583242ab739b33d9f1ef0b9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 23 Sep 2026 16:01:40 +0200 Subject: [PATCH] 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. --- .../Roadtrip/RoadtripSidebar.test.tsx | 17 ++++ .../components/Roadtrip/RoadtripSidebar.tsx | 11 ++- .../components/Roadtrip/nightBookend.test.ts | 20 ++++- .../src/components/Roadtrip/nightBookend.ts | 17 ++++ .../screens/trip/roadtrip/MRoadtripRows.tsx | 6 +- .../useTripPlanner.roadtrip.test.tsx | 80 +++++++++++++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 82 +++++++++++++------ .../unit/mobile/trip/MRoadtripRows.test.tsx | 13 ++- shared/src/i18n/ar/roadtrip.ts | 1 + shared/src/i18n/br/roadtrip.ts | 1 + shared/src/i18n/ca/roadtrip.ts | 1 + shared/src/i18n/cs/roadtrip.ts | 1 + shared/src/i18n/de/roadtrip.ts | 1 + shared/src/i18n/en/roadtrip.ts | 1 + shared/src/i18n/es/roadtrip.ts | 1 + shared/src/i18n/fr/roadtrip.ts | 1 + shared/src/i18n/gr/roadtrip.ts | 1 + shared/src/i18n/hu/roadtrip.ts | 1 + shared/src/i18n/id/roadtrip.ts | 1 + shared/src/i18n/it/roadtrip.ts | 1 + shared/src/i18n/ja/roadtrip.ts | 1 + shared/src/i18n/ko/roadtrip.ts | 1 + shared/src/i18n/nl/roadtrip.ts | 1 + shared/src/i18n/pl/roadtrip.ts | 1 + shared/src/i18n/ru/roadtrip.ts | 1 + shared/src/i18n/sv/roadtrip.ts | 1 + shared/src/i18n/tr/roadtrip.ts | 1 + shared/src/i18n/uk/roadtrip.ts | 1 + shared/src/i18n/vi/roadtrip.ts | 1 + shared/src/i18n/zh-TW/roadtrip.ts | 1 + shared/src/i18n/zh/roadtrip.ts | 1 + shared/src/roadtrip/corridor.spec.ts | 39 +++++++++ shared/src/roadtrip/corridor.ts | 36 +++++++- wiki/Road-Trip.md | 4 +- 34 files changed, 307 insertions(+), 41 deletions(-) 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 ? ( + + + ) : 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.