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.