mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(roadtrip): the check-out leaves the drive, and a long stay carries the clock
A check-out is the LATEST the room has to be handed back, never the earliest anybody may drive on, so it says nothing the chain can use. It shaped four things it had no business shaping: the departure the day was built from, the length the rail printed for a booked night, a day-selector and a clock field in the stop dialog, and the branch that sent a night's STAY badge to the booking form because its length was not editable here. All four go. The booking keeps its check-out; it is a booking detail and lives under Days, where it is entered. What replaces it is the thing that was always the honest answer: how long you stand there. That is the traveller's own statement rather than a deadline, and a stay running past midnight now carries the clock into the morning after — a stop pinned before it ends keeps its hour and collects the late finding, as it would from any leg it cannot make. A stay spanning whole days walks the clock forward over them. An ordinary evening hands on nothing: the night between two days is not a wait somebody is serving out. The stay dialog goes to a full day rather than four hours, since it is now the only place a night is given its hours, and offers eight and twelve as presets. Every stop answers the same question the same way. Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so the first time each dialog was ever opened a pale block flashed under the planner and never again once its chunk was cached.
This commit is contained in:
@@ -3,7 +3,7 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, LogOut,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
@@ -837,7 +837,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content-secondary"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
|
||||
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
@@ -951,28 +951,6 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrival — the one value in the rail the user may have decided themselves.
|
||||
*
|
||||
* A pinned time is filled, pilled and carries the clock; a computed one is bare text.
|
||||
* Fill, shape and icon are three signals where a font weight used to be one, and this is
|
||||
* the state the whole view gets opened for. It is also the only filled accent left in the
|
||||
* rail: selection moved to the row's own background, so nothing else competes with it.
|
||||
*/
|
||||
function CheckoutDeparture({ stop, entry }: { stop: RoadtripStop; entry?: ScheduleEntry }): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
if (stop.checkoutAt === undefined || !entry?.departure) return null
|
||||
return <Tooltip label={t('roadtrip.spill.departs', { time: formatClockTime(entry.departure, is12h) })}>
|
||||
<span dir="ltr" tabIndex={0} className="inline-flex shrink-0 items-center gap-1 rounded bg-surface-secondary px-1 py-0.5 align-middle whitespace-nowrap font-medium text-[length:calc(10px*var(--fs-scale-caption,1))] leading-none tabular-nums text-content-muted">
|
||||
{/* Leaving, not just a time of day: this chip is the one clock in the rail that
|
||||
says when you give the room back, and beside an arrival it read as a second
|
||||
arrival. The door-out mark is what a hotel desk calls check-out. */}
|
||||
<LogOut size={10} aria-hidden />{formatClockTime(entry.departure, is12h)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -1108,7 +1086,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
className="flex min-w-0 items-center gap-2 font-semibold leading-6 tracking-[-0.012em] text-content"
|
||||
style={{ fontSize: FS.name }}
|
||||
>
|
||||
<span className={stop.stopType === 'charging' || stop.checkoutAt !== undefined ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
|
||||
<span className={stop.stopType === 'charging' ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
|
||||
</span>
|
||||
{/* Two halves under one border: the word says what the number means, so the
|
||||
number needs no unit of explanation beside it. */}
|
||||
@@ -1271,7 +1249,7 @@ function SpillBlock({ spill, children }: {
|
||||
The stop it leaves from is not named — it is drawn on yesterday's card
|
||||
directly above, and a second row for it would read as a stop made
|
||||
twice. */}
|
||||
{departure && spill.fromStop?.checkoutAt === undefined ? (
|
||||
{departure ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-auto shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums"
|
||||
|
||||
@@ -12,10 +12,17 @@ import { formatClockTime } from '../../utils/formatters'
|
||||
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
|
||||
* the slider is for.
|
||||
*/
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120]
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120, 480, 720]
|
||||
|
||||
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
|
||||
const MAX = 240
|
||||
/**
|
||||
* As far as the slider goes, in minutes.
|
||||
*
|
||||
* A full day. It used to stop at four hours, on the reasoning that anything longer is a
|
||||
* day rather than a stop — which held while a booked night got its length from the
|
||||
* check-out instead. The drive no longer reads a check-out, so this is the only place a
|
||||
* night is given its hours, and four of them is not a night.
|
||||
*/
|
||||
const MAX = 1440
|
||||
/** The step the slider and the two buttons move in. */
|
||||
const STEP = 5
|
||||
|
||||
|
||||
@@ -191,7 +191,11 @@ describe('RoadtripStopPopup', () => {
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Check out on')).toBeInTheDocument()
|
||||
// Check-in and nothing else. A check-out says when the room has to be back, never
|
||||
// when anybody drives on, so the drive has no use for it and does not ask.
|
||||
expect(screen.getByRole('textbox', { name: 'Check-in' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Check out on')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox', { name: 'Check-out' })).not.toBeInTheDocument()
|
||||
// The two questions a pause asks are gone: a hotel has no kind and no dwell.
|
||||
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -285,7 +289,10 @@ describe('editing a roadtrip stop', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
it('loads and preserves an existing overnight booking', async () => {
|
||||
it('loads an existing overnight booking and keeps its check-in', async () => {
|
||||
// The stored check-out is left where it is rather than resaved: this surface no
|
||||
// longer asks for one, and writing back a blank would quietly drop what somebody
|
||||
// entered under Days.
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 }, editing: { placeId: 7, stopType: 'hotel', dwellMinutes: 30, accommodationId: 8, checkIn: '16:00', checkOut: '10:00' } })} {...noop} onSaveNight={onSaveNight} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import StayPortals from './StayPortals'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
@@ -76,7 +74,7 @@ interface RoadtripStopPopupProps {
|
||||
export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t, language } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
// Through the same guard every other external link in the app goes through.
|
||||
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
|
||||
// routinely written without a scheme — `www.hotel.de` as a raw href is a
|
||||
@@ -91,9 +89,7 @@ export default function RoadtripStopPopup({
|
||||
// means everywhere else — but both offer the other, because a campsite is somewhere
|
||||
// people sleep and a hotel is somewhere people stop for lunch.
|
||||
const [night, setNight] = useState<boolean>(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel')
|
||||
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
|
||||
const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '')
|
||||
const [checkOut, setCheckOut] = useState(draft?.editing?.checkOut ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!draft) return null
|
||||
@@ -107,9 +103,13 @@ export default function RoadtripStopPopup({
|
||||
try {
|
||||
if (asNight) {
|
||||
await onSaveNight?.({
|
||||
endDayId: endDayId ?? overnight!.defaultEndDayId,
|
||||
// The night runs to the day the rail suggests. Choosing another one is a
|
||||
// booking decision, and the booking lives under Days.
|
||||
endDayId: overnight!.defaultEndDayId,
|
||||
checkIn,
|
||||
checkOut,
|
||||
// Passed back exactly as it was loaded. This surface no longer asks for a
|
||||
// check-out, and sending a blank would quietly erase one entered under Days.
|
||||
checkOut: draft.editing?.checkOut ?? '',
|
||||
})
|
||||
} else {
|
||||
await onSave({ stopType, dwellMinutes: dwell })
|
||||
@@ -178,44 +178,17 @@ export default function RoadtripStopPopup({
|
||||
|
||||
{asNight ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
{/* Check-in only, and optional at that: it is the hour the drive can stop
|
||||
for the night, which is the one thing about a booking the chain uses.
|
||||
A check-out says when the room has to be back, never when anybody sets
|
||||
off, so it is a booking detail and lives under Days with the rest of
|
||||
them. A hotel found on a map has no idea when its reception opens. */}
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stay.checkOutDay')}
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<CustomSelect
|
||||
value={String(endDayId ?? overnight!.defaultEndDayId)}
|
||||
onChange={value => setEndDayId(Number(value))}
|
||||
options={overnight!.days.map(d => ({
|
||||
value: String(d.id),
|
||||
// Through the shared helper, like every other day date in
|
||||
// this feature. Interpolating the column gave this one
|
||||
// dropdown a bare ISO date while the rail beside it showed
|
||||
// the same day in the reader's own order.
|
||||
label: d.date
|
||||
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
|
||||
: t('roadtrip.stay.dayNumber', { number: d.number }),
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Both optional: leaving them empty is normal, and the server stores null.
|
||||
A hotel found on a map has no idea when its reception opens. */}
|
||||
<div className="flex gap-3">
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkOut')}
|
||||
</span>
|
||||
<CustomTimePicker value={checkOut} onChange={setCheckOut} aria-label={t('day.checkOut')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
<CustomTimePicker value={checkIn} onChange={setCheckIn} aria-label={t('day.checkIn')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{websiteHref ? (
|
||||
@@ -299,7 +272,7 @@ export default function RoadtripStopPopup({
|
||||
</div>
|
||||
{overnight ? <StayPortals lat={draft.poi.lat} lng={draft.poi.lng} name={draft.poi.name} camping={draft.poi.category === 'campsite'}
|
||||
arrival={overnight.days.find(day => day.id === draft.dayId)?.date}
|
||||
departure={overnight.days.find(day => day.id === (endDayId ?? overnight.defaultEndDayId))?.date} /> : null}
|
||||
departure={overnight.days.find(day => day.id === overnight.defaultEndDayId)?.date} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes,
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { spillChains } from './nightSpill'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
@@ -71,14 +71,14 @@ const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from
|
||||
|
||||
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => {
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
// Check-in only. A check-out is the LATEST the room has to be handed back, not the
|
||||
// earliest anybody may leave, so it says nothing about when the drive sets off and
|
||||
// has no business in the chain. It stays a booking detail, shown under Days.
|
||||
const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId)
|
||||
const checkoutDay = days.find(day => day.id === stay?.end_day_id)?.day_number
|
||||
const checkoutTime = parseClock(stay?.check_out)
|
||||
return {
|
||||
...(checkoutDay != null && checkoutTime !== null ? { checkoutAt: checkoutDay * 1440 + checkoutTime } : {}),
|
||||
assignmentId: a.id,
|
||||
ownerDayId,
|
||||
ownerIndex,
|
||||
@@ -205,7 +205,7 @@ export function useRoadtripRoutes(
|
||||
const stops = (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.map(a => asStop(a, d.id, 0, accommodations))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
// The index is filled in after the drop, because it is the index into THIS
|
||||
// list: an assignment whose place has no coordinates never becomes a stop, and
|
||||
@@ -233,7 +233,7 @@ export function useRoadtripRoutes(
|
||||
stops: (assignments[String(d.id)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.map(a => asStop(a, d.id, 0, accommodations))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
.map((s, i) => ({ ...s, ownerIndex: i })),
|
||||
}))
|
||||
|
||||
@@ -96,10 +96,15 @@ const TransportModal = lazyWithRetry(() =>
|
||||
* No label: ErrorBoundary lets label win over the panel level and would title a
|
||||
* broken packing list "This plugin could not be shown".
|
||||
*/
|
||||
function LazyPanel({ id, children }: { id: string; children: React.ReactNode }): React.ReactElement {
|
||||
function LazyPanel({ id, children, overlay }: { id: string; children: React.ReactNode; overlay?: boolean }): React.ReactElement {
|
||||
return (
|
||||
<ErrorBoundary boundaryId={`planner-panel:${id}`}>
|
||||
<Suspense fallback={<div className="h-full w-full min-h-[180px] rounded-xl bg-surface-secondary animate-pulse" />}>
|
||||
{/* A panel holds its place with a skeleton while its chunk arrives; a dialog has no
|
||||
place to hold. Drawn in the page flow, that skeleton was a pale block flashing
|
||||
under the planner the first time each dialog was ever opened, and never again
|
||||
once the chunk was cached. Nothing is the right placeholder for something that
|
||||
is about to cover the screen anyway. */}
|
||||
<Suspense fallback={overlay ? null : <div className="h-full w-full min-h-[180px] rounded-xl bg-surface-secondary animate-pulse" />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
@@ -1025,7 +1030,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
{/* Which track a day drives along. Mounted only while it is open, so the chunk and
|
||||
the parsing of every imported line stay out of an ordinary planner session. */}
|
||||
{followTrack.dayId !== null && (
|
||||
<LazyPanel id="roadtrip-track">
|
||||
<LazyPanel id="roadtrip-track" overlay>
|
||||
<RoadtripTrackModal
|
||||
follow={followTrack}
|
||||
dayNumber={roadtripRoutes.days.find(d => d.dayId === followTrack.dayId)?.dayNumber ?? 0}
|
||||
@@ -1033,12 +1038,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stayDraft && (
|
||||
<LazyPanel id="roadtrip-stay">
|
||||
<LazyPanel id="roadtrip-stay" overlay>
|
||||
<RoadtripStayModal stop={stayDraft} onClose={() => setStayDraft(null)} onSave={setRoadtripStay} />
|
||||
</LazyPanel>
|
||||
)}
|
||||
{stopDraft && (
|
||||
<LazyPanel id="roadtrip-stop">
|
||||
<LazyPanel id="roadtrip-stop" overlay>
|
||||
<RoadtripStopPopup
|
||||
draft={stopDraft}
|
||||
duplicateName={stopDraftDuplicate}
|
||||
|
||||
@@ -1727,13 +1727,17 @@ export function useTripPlanner() {
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
|
||||
/**
|
||||
* How long the drive stands here, for every stop alike.
|
||||
*
|
||||
* A booked night used to be sent to the booking form instead, because its duration was
|
||||
* read off the check-out and there was nothing here to set. The drive no longer reads
|
||||
* a check-out at all: a night is a stop that takes as long as it takes, and asking how
|
||||
* long is the same question at a hotel as at a viewpoint.
|
||||
*/
|
||||
const editRoadtripStay = useCallback((draft: NonNullable<typeof stayDraft>) => {
|
||||
const stay = tripAccommodations.find(stay => stay.place_id === draft.placeId && stay.check_out)
|
||||
const place = places.find(place => place.id === draft.placeId)
|
||||
const visit = stay && assignments[String(stay.start_day_id)]?.find(visit => visit.place_id === draft.placeId)
|
||||
if (stay && place && visit) openPlaceEditor(place, visit.id)
|
||||
else setStayDraft(draft)
|
||||
}, [tripAccommodations, places, assignments, openPlaceEditor])
|
||||
setStayDraft(draft)
|
||||
}, [])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
|
||||
@@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'وجهة',
|
||||
'roadtrip.stay.mode.pause': 'استراحة',
|
||||
'roadtrip.stay.mode.night': 'مبيت',
|
||||
'roadtrip.stay.checkOutDay': 'المغادرة في',
|
||||
'roadtrip.stay.dayNumber': 'اليوم {number}',
|
||||
'roadtrip.stay.nightAdded': 'أُضيف كمبيت',
|
||||
'roadtrip.stay.nightAction': 'إضافة كمبيت',
|
||||
'roadtrip.stop.makeService': 'اجعله توقفاً في الطريق',
|
||||
|
||||
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Um destino',
|
||||
'roadtrip.stay.mode.pause': 'Pausa',
|
||||
'roadtrip.stay.mode.night': 'Pernoite',
|
||||
'roadtrip.stay.checkOutDay': 'Saída em',
|
||||
'roadtrip.stay.dayNumber': 'Dia {number}',
|
||||
'roadtrip.stay.nightAdded': 'Adicionado como pernoite',
|
||||
'roadtrip.stay.nightAction': 'Adicionar como pernoite',
|
||||
'roadtrip.stop.makeService': 'Tornar uma parada no caminho',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Una destinació',
|
||||
'roadtrip.stay.mode.pause': 'Parada',
|
||||
'roadtrip.stay.mode.night': 'Pernoctar',
|
||||
'roadtrip.stay.checkOutDay': 'Sortida el',
|
||||
'roadtrip.stay.dayNumber': 'Dia {number}',
|
||||
'roadtrip.stay.nightAdded': 'Afegit com a pernoctació',
|
||||
'roadtrip.stay.nightAction': 'Afegeix com a pernoctació',
|
||||
'roadtrip.stop.makeService': 'Converteix en parada del camí',
|
||||
|
||||
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Cíl cesty',
|
||||
'roadtrip.stay.mode.pause': 'Pauza',
|
||||
'roadtrip.stay.mode.night': 'Nocleh',
|
||||
'roadtrip.stay.checkOutDay': 'Odjezd',
|
||||
'roadtrip.stay.dayNumber': 'Den {number}',
|
||||
'roadtrip.stay.nightAdded': 'Přidáno jako nocleh',
|
||||
'roadtrip.stay.nightAction': 'Přidat jako nocleh',
|
||||
'roadtrip.stop.makeService': 'Změnit na zastávku po cestě',
|
||||
|
||||
@@ -143,8 +143,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Ein Ziel',
|
||||
'roadtrip.stay.mode.pause': 'Pause',
|
||||
'roadtrip.stay.mode.night': 'Übernachten',
|
||||
'roadtrip.stay.checkOutDay': 'Abreise am',
|
||||
'roadtrip.stay.dayNumber': 'Tag {number}',
|
||||
'roadtrip.stay.nightAdded': 'Als Übernachtung hinzugefügt',
|
||||
'roadtrip.stay.nightAction': 'Als Übernachtung hinzufügen',
|
||||
'roadtrip.stop.makeService': 'Zum Halt unterwegs machen',
|
||||
|
||||
@@ -142,8 +142,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'A destination',
|
||||
'roadtrip.stay.mode.pause': 'Pause',
|
||||
'roadtrip.stay.mode.night': 'Overnight',
|
||||
'roadtrip.stay.checkOutDay': 'Check out on',
|
||||
'roadtrip.stay.dayNumber': 'Day {number}',
|
||||
'roadtrip.stay.nightAdded': 'Added as an overnight stay',
|
||||
'roadtrip.stay.nightAction': 'Add as an overnight stay',
|
||||
'roadtrip.stop.makeService': 'Make it a stop on the way',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Un destino',
|
||||
'roadtrip.stay.mode.pause': 'Parada',
|
||||
'roadtrip.stay.mode.night': 'Pernoctar',
|
||||
'roadtrip.stay.checkOutDay': 'Salida el',
|
||||
'roadtrip.stay.dayNumber': 'Día {number}',
|
||||
'roadtrip.stay.nightAdded': 'Añadido como pernoctación',
|
||||
'roadtrip.stay.nightAction': 'Añadir como pernoctación',
|
||||
'roadtrip.stop.makeService': 'Convertir en parada del camino',
|
||||
|
||||
@@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Une destination',
|
||||
'roadtrip.stay.mode.pause': 'Pause',
|
||||
'roadtrip.stay.mode.night': 'Nuitée',
|
||||
'roadtrip.stay.checkOutDay': 'Départ le',
|
||||
'roadtrip.stay.dayNumber': 'Jour {number}',
|
||||
'roadtrip.stay.nightAdded': 'Ajouté comme nuitée',
|
||||
'roadtrip.stay.nightAction': 'Ajouter comme nuitée',
|
||||
'roadtrip.stop.makeService': 'En faire un arrêt en chemin',
|
||||
|
||||
@@ -149,8 +149,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Προορισμός',
|
||||
'roadtrip.stay.mode.pause': 'Στάση',
|
||||
'roadtrip.stay.mode.night': 'Διανυκτέρευση',
|
||||
'roadtrip.stay.checkOutDay': 'Αναχώρηση',
|
||||
'roadtrip.stay.dayNumber': 'Ημέρα {number}',
|
||||
'roadtrip.stay.nightAdded': 'Προστέθηκε ως διανυκτέρευση',
|
||||
'roadtrip.stay.nightAction': 'Προσθήκη ως διανυκτέρευση',
|
||||
'roadtrip.stop.makeService': 'Μετατροπή σε στάση στον δρόμο',
|
||||
|
||||
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Úti cél',
|
||||
'roadtrip.stay.mode.pause': 'Szünet',
|
||||
'roadtrip.stay.mode.night': 'Éjszakázás',
|
||||
'roadtrip.stay.checkOutDay': 'Kijelentkezés',
|
||||
'roadtrip.stay.dayNumber': '{number}. nap',
|
||||
'roadtrip.stay.nightAdded': 'Hozzáadva éjszakázásként',
|
||||
'roadtrip.stay.nightAction': 'Hozzáadás éjszakázásként',
|
||||
'roadtrip.stop.makeService': 'Legyen útközbeni megálló',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Sebuah tujuan',
|
||||
'roadtrip.stay.mode.pause': 'Istirahat',
|
||||
'roadtrip.stay.mode.night': 'Menginap',
|
||||
'roadtrip.stay.checkOutDay': 'Check out',
|
||||
'roadtrip.stay.dayNumber': 'Hari {number}',
|
||||
'roadtrip.stay.nightAdded': 'Ditambahkan sebagai menginap',
|
||||
'roadtrip.stay.nightAction': 'Tambahkan sebagai menginap',
|
||||
'roadtrip.stop.makeService': 'Jadikan perhentian di jalan',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Una destinazione',
|
||||
'roadtrip.stay.mode.pause': 'Sosta',
|
||||
'roadtrip.stay.mode.night': 'Pernottamento',
|
||||
'roadtrip.stay.checkOutDay': 'Partenza il',
|
||||
'roadtrip.stay.dayNumber': 'Giorno {number}',
|
||||
'roadtrip.stay.nightAdded': 'Aggiunto come pernottamento',
|
||||
'roadtrip.stay.nightAction': 'Aggiungi come pernottamento',
|
||||
'roadtrip.stop.makeService': 'Trasforma in sosta lungo il percorso',
|
||||
|
||||
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stay.mode.pause': '休憩',
|
||||
'roadtrip.stay.mode.night': '宿泊',
|
||||
'roadtrip.stay.checkOutDay': 'チェックアウト日',
|
||||
'roadtrip.stay.dayNumber': '{number}日目',
|
||||
'roadtrip.stay.nightAdded': '宿泊として追加しました',
|
||||
'roadtrip.stay.nightAction': '宿泊として追加',
|
||||
'roadtrip.stop.makeService': '途中の立ち寄りにする',
|
||||
|
||||
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': '목적지',
|
||||
'roadtrip.stay.mode.pause': '휴식',
|
||||
'roadtrip.stay.mode.night': '숙박',
|
||||
'roadtrip.stay.checkOutDay': '체크아웃 날짜',
|
||||
'roadtrip.stay.dayNumber': '{number}일차',
|
||||
'roadtrip.stay.nightAdded': '숙박으로 추가했습니다',
|
||||
'roadtrip.stay.nightAction': '숙박으로 추가',
|
||||
'roadtrip.stop.makeService': '경유 정차로 변경',
|
||||
|
||||
@@ -148,8 +148,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Een bestemming',
|
||||
'roadtrip.stay.mode.pause': 'Pauze',
|
||||
'roadtrip.stay.mode.night': 'Overnachten',
|
||||
'roadtrip.stay.checkOutDay': 'Uitchecken op',
|
||||
'roadtrip.stay.dayNumber': 'Dag {number}',
|
||||
'roadtrip.stay.nightAdded': 'Toegevoegd als overnachting',
|
||||
'roadtrip.stay.nightAction': 'Toevoegen als overnachting',
|
||||
'roadtrip.stop.makeService': 'Maak er een stop onderweg van',
|
||||
|
||||
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Cel podróży',
|
||||
'roadtrip.stay.mode.pause': 'Postój',
|
||||
'roadtrip.stay.mode.night': 'Nocleg',
|
||||
'roadtrip.stay.checkOutDay': 'Wymeldowanie',
|
||||
'roadtrip.stay.dayNumber': 'Dzień {number}',
|
||||
'roadtrip.stay.nightAdded': 'Dodano jako nocleg',
|
||||
'roadtrip.stay.nightAction': 'Dodaj jako nocleg',
|
||||
'roadtrip.stop.makeService': 'Zamień w postój po drodze',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Пункт назначения',
|
||||
'roadtrip.stay.mode.pause': 'Остановка',
|
||||
'roadtrip.stay.mode.night': 'Ночёвка',
|
||||
'roadtrip.stay.checkOutDay': 'Выезд',
|
||||
'roadtrip.stay.dayNumber': 'День {number}',
|
||||
'roadtrip.stay.nightAdded': 'Добавлено как ночёвка',
|
||||
'roadtrip.stay.nightAction': 'Добавить как ночёвку',
|
||||
'roadtrip.stop.makeService': 'Сделать остановкой в пути',
|
||||
|
||||
@@ -145,8 +145,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Ett resmål',
|
||||
'roadtrip.stay.mode.pause': 'Paus',
|
||||
'roadtrip.stay.mode.night': 'Övernattning',
|
||||
'roadtrip.stay.checkOutDay': 'Utcheckning',
|
||||
'roadtrip.stay.dayNumber': 'Dag {number}',
|
||||
'roadtrip.stay.nightAdded': 'Tillagd som övernattning',
|
||||
'roadtrip.stay.nightAction': 'Lägg till som övernattning',
|
||||
'roadtrip.stop.makeService': 'Gör det till ett stopp på vägen',
|
||||
|
||||
@@ -147,8 +147,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Bir varış noktası',
|
||||
'roadtrip.stay.mode.pause': 'Mola',
|
||||
'roadtrip.stay.mode.night': 'Konaklama',
|
||||
'roadtrip.stay.checkOutDay': 'Çıkış günü',
|
||||
'roadtrip.stay.dayNumber': '{number}. gün',
|
||||
'roadtrip.stay.nightAdded': 'Konaklama olarak eklendi',
|
||||
'roadtrip.stay.nightAction': 'Konaklama olarak ekle',
|
||||
'roadtrip.stop.makeService': 'Yol üzerinde molaya dönüştür',
|
||||
|
||||
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Пункт призначення',
|
||||
'roadtrip.stay.mode.pause': 'Зупинка',
|
||||
'roadtrip.stay.mode.night': 'Ночівля',
|
||||
'roadtrip.stay.checkOutDay': 'Виїзд',
|
||||
'roadtrip.stay.dayNumber': 'День {number}',
|
||||
'roadtrip.stay.nightAdded': 'Додано як ночівлю',
|
||||
'roadtrip.stay.nightAction': 'Додати як ночівлю',
|
||||
'roadtrip.stop.makeService': 'Зробити зупинкою в дорозі',
|
||||
|
||||
@@ -146,8 +146,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': 'Một điểm đến',
|
||||
'roadtrip.stay.mode.pause': 'Nghỉ',
|
||||
'roadtrip.stay.mode.night': 'Qua đêm',
|
||||
'roadtrip.stay.checkOutDay': 'Trả phòng ngày',
|
||||
'roadtrip.stay.dayNumber': 'Ngày {number}',
|
||||
'roadtrip.stay.nightAdded': 'Đã thêm làm nơi nghỉ đêm',
|
||||
'roadtrip.stay.nightAction': 'Thêm làm nơi nghỉ đêm',
|
||||
'roadtrip.stop.makeService': 'Biến thành điểm dừng dọc đường',
|
||||
|
||||
@@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stay.mode.pause': '停留',
|
||||
'roadtrip.stay.mode.night': '過夜',
|
||||
'roadtrip.stay.checkOutDay': '退房日',
|
||||
'roadtrip.stay.dayNumber': '第 {number} 天',
|
||||
'roadtrip.stay.nightAdded': '已新增為過夜住宿',
|
||||
'roadtrip.stay.nightAction': '新增為過夜住宿',
|
||||
'roadtrip.stop.makeService': '設為途中停靠',
|
||||
|
||||
@@ -137,8 +137,6 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stay.mode.pause': '停留',
|
||||
'roadtrip.stay.mode.night': '过夜',
|
||||
'roadtrip.stay.checkOutDay': '退房日',
|
||||
'roadtrip.stay.dayNumber': '第 {number} 天',
|
||||
'roadtrip.stay.nightAdded': '已添加为过夜住宿',
|
||||
'roadtrip.stay.nightAction': '添加为过夜住宿',
|
||||
'roadtrip.stop.makeService': '设为途中停靠',
|
||||
|
||||
@@ -90,7 +90,6 @@ export function assembleRoadtrip({
|
||||
d.stops.map((s) => ({
|
||||
anchor: s.time ?? s.checkInTime ?? null,
|
||||
dwellMinutes: s.dwellMinutes,
|
||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||
})),
|
||||
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
|
||||
),
|
||||
@@ -160,15 +159,7 @@ export function assembleRoadtrip({
|
||||
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)];
|
||||
const line = spurFor(snap);
|
||||
if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) });
|
||||
const entry = schedule.entries[index];
|
||||
const arrival = parseClock(entry?.arrival);
|
||||
const dwellMinutes =
|
||||
s.checkoutAt !== undefined && arrival !== null
|
||||
? Math.max(0, s.checkoutAt - ((chain.dayNumber + (entry?.dayOffset ?? 0)) * 1440 + arrival))
|
||||
: s.checkoutAt !== undefined
|
||||
? null
|
||||
: s.dwellMinutes;
|
||||
return { ...s, dwellMinutes, offRoadMeters: line ? snap!.meters : null };
|
||||
return { ...s, offRoadMeters: line ? snap!.meters : null };
|
||||
});
|
||||
|
||||
const drive = {
|
||||
|
||||
@@ -18,15 +18,10 @@ export function continueSchedule(
|
||||
if (departure === null || duration === undefined) continue;
|
||||
const previousArrival = parseClock(previous.arrival);
|
||||
const departureAt =
|
||||
stops[i - 1]!.checkoutAt !== undefined
|
||||
? stops[i - 1]!.checkoutAt! - dayNumber * 1440
|
||||
: previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
|
||||
previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
|
||||
const arrivalAt = departureAt + Math.round(duration / 60);
|
||||
const stop = stops[i]!;
|
||||
const leaveAt =
|
||||
stop.checkoutAt === undefined
|
||||
? arrivalAt + (stop.dwellMinutes ?? 0)
|
||||
: Math.max(arrivalAt, stop.checkoutAt - dayNumber * 1440);
|
||||
const leaveAt = arrivalAt + (stop.dwellMinutes ?? 0);
|
||||
entry.arrival = formatClock(arrivalAt);
|
||||
entry.departure = formatClock(leaveAt);
|
||||
entry.dayOffset = Math.floor(arrivalAt / 1440);
|
||||
|
||||
@@ -366,47 +366,28 @@ describe('daily travel window', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('booked checkout', () => {
|
||||
it('waits at the hotel until noon on the checkout day', () => {
|
||||
const hotel = stop(2, { checkoutAt: 2 * 1440 + 720, dwellMinutes: 30 });
|
||||
const stops = [stop(1), hotel, stop(3)];
|
||||
const plan = calculate(stops, [60, 60]);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:00');
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.departure).toBe('12:00');
|
||||
expect(plan.chains[1]!.schedule.entries[0]!.arrival).toBe('12:00');
|
||||
expect(plan.chains[1]!.schedule.entries[1]!.arrival).toBe('13:00');
|
||||
});
|
||||
it('honours an early checkout and multiple nights', () => {
|
||||
const stops = [stop(1, { checkoutAt: 3 * 1440 + 420 }), stop(2)];
|
||||
const plan = calculate(stops, [60], [day(2), day(3)]);
|
||||
expect(plan.issue).toBeNull();
|
||||
expect(plan.chains[2]!.schedule.entries[0]!.arrival).toBe('07:00');
|
||||
expect(plan.chains[2]!.schedule.entries[1]!.arrival).toBe('08:00');
|
||||
});
|
||||
it('does not move time backwards when arrival is after checkout', () => {
|
||||
const stops = [stop(1, { time: '13:00', checkoutAt: 1440 + 720 }), stop(2)];
|
||||
const plan = calculate(stops, [60]);
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('14:00');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps checkout when the drive arrives after suggested check-in', () => {
|
||||
it('waits for a check-in the drive overshoots rather than rushing it', () => {
|
||||
const stops = [
|
||||
stop(1, { time: '07:00', dwellMinutes: 60 }),
|
||||
stop(2, { checkInTime: '09:11', checkoutAt: 2 * 1440 + 600 }),
|
||||
stop(2, { checkInTime: '09:11' }),
|
||||
stop(3),
|
||||
];
|
||||
const plan = calculate(stops, [72.5, 29]);
|
||||
expect(plan.issue).toBeNull();
|
||||
// Arrives two minutes past the hour it may check in from, and is left there rather
|
||||
// than pulled back to the round number.
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('09:13');
|
||||
expect(plan.chains[1]!.stops.at(-1)!.placeId).toBe(3);
|
||||
expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29');
|
||||
const stops0 = plan.chains[0]!.stops;
|
||||
expect(stops0.at(-1)!.placeId).toBe(3);
|
||||
// And carries straight on from there: nothing holds the traveller at the stop.
|
||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('09:42');
|
||||
});
|
||||
|
||||
it('waits for check-in without treating it as a fixed appointment', () => {
|
||||
const plan = calculate([stop(1), stop(2, { checkInTime: '15:00', checkoutAt: 2 * 1440 + 600 }), stop(3)], [60, 29]);
|
||||
const plan = calculate([stop(1), stop(2, { checkInTime: '15:00' }), stop(3)], [60, 29]);
|
||||
expect(plan.issue).toBeNull();
|
||||
// The check-in is a door opening, not an appointment: the drive waits for it and then
|
||||
// carries straight on, rather than the day being rebuilt around it.
|
||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
|
||||
expect(plan.chains[1]!.schedule.entries.at(-1)!.arrival).toBe('10:29');
|
||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
|
||||
});
|
||||
|
||||
@@ -181,7 +181,6 @@ export function planDayWindow(
|
||||
name: labels.end,
|
||||
time: null,
|
||||
dwellMinutes: 0,
|
||||
checkoutAt: undefined,
|
||||
checkInTime: undefined,
|
||||
stopType: null,
|
||||
fillPercent: null,
|
||||
@@ -278,26 +277,6 @@ export function planDayWindow(
|
||||
append(stop, clock);
|
||||
previous = stop;
|
||||
|
||||
if (stop.checkoutAt !== undefined) {
|
||||
const checkoutDay = Math.floor(stop.checkoutAt / 1440);
|
||||
const checkoutTime = stop.checkoutAt % 1440;
|
||||
const arrivalAt = number * 1440 + clock;
|
||||
if (stop.checkoutAt > arrivalAt) {
|
||||
const entries = chainAt(number).schedule.entries;
|
||||
entries[entries.length - 1]!.departure = formatClock(checkoutTime);
|
||||
while (number < checkoutDay) {
|
||||
previous = night(previous);
|
||||
if (issue) return failed(issue);
|
||||
}
|
||||
clock = checkoutTime;
|
||||
if (previous.automaticNight?.phase === 'start') {
|
||||
const entries = chainAt(number).schedule.entries;
|
||||
entries[entries.length - 1]!.arrival = formatClock(clock);
|
||||
entries[entries.length - 1]!.departure = formatClock(clock);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let dwell = Math.max(0, stop.dwellMinutes ?? 0);
|
||||
while (dwell > 0) {
|
||||
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
|
||||
|
||||
@@ -276,4 +276,66 @@ describe('spillChains', () => {
|
||||
expect(chains[1]!.spills).toHaveLength(1);
|
||||
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
|
||||
});
|
||||
|
||||
describe('a stay that runs past midnight carries the clock into the next day', () => {
|
||||
// Standing somewhere for twenty-four hours is not over when the date changes. The
|
||||
// day after used to begin at nothing, so a trip whose second day had no pinned time
|
||||
// showed no times at all, right after the first day had said exactly when it ends.
|
||||
it('FE-NIGHTSPILL-013: the next day starts where the stay ends', () => {
|
||||
const plan = [
|
||||
day(1, 1, [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', time: '07:56' }),
|
||||
stop({ assignmentId: 2, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 }),
|
||||
]),
|
||||
day(2, 2, [stop({ assignmentId: 3, name: 'Panorama' }), stop({ assignmentId: 4, name: 'Bergedorf' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
|
||||
|
||||
expect(dayTwo.schedule.entries[0]!.arrival).toBe('08:00');
|
||||
expect(dayTwo.schedule.entries[1]!.arrival).toBe('08:11');
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-014: an ordinary evening hands on nothing', () => {
|
||||
// The night between two days is not a wait somebody is serving out. Only a stay
|
||||
// that genuinely runs past midnight reaches into the morning.
|
||||
const plan = [
|
||||
day(1, 1, [stop({ assignmentId: 1, name: 'Hamburg', time: '18:00', dwellMinutes: 60 })]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Panorama' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
|
||||
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-015: a stop pinned before the stay ends keeps its clock and is flagged', () => {
|
||||
const plan = [
|
||||
day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 24 * 60 })]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Panorama', time: '07:00' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
|
||||
|
||||
// Never moved: the hour is the traveller's.
|
||||
expect(dayTwo.schedule.entries[0]!.arrival).toBe('07:00');
|
||||
expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'late', minutes: 60 });
|
||||
});
|
||||
|
||||
it('FE-NIGHTSPILL-016: a stay of two whole days walks the clock forward day by day', () => {
|
||||
const plan = [
|
||||
day(1, 1, [stop({ assignmentId: 1, name: 'Mercure', time: '08:00', dwellMinutes: 48 * 60 })]),
|
||||
day(2, 2, [stop({ assignmentId: 2, name: 'Nothing planned' })]),
|
||||
day(3, 3, [stop({ assignmentId: 3, name: 'Berlin' })]),
|
||||
];
|
||||
|
||||
const chains = spillChains(plan, [], everyLeg(11));
|
||||
|
||||
// Day two is still inside the stay, so it cannot begin at all.
|
||||
expect(chains.find((c) => c.dayNumber === 2)!.schedule.entries[0]!.arrival).toBeNull();
|
||||
expect(chains.find((c) => c.dayNumber === 3)!.schedule.entries[0]!.arrival).toBe('08:00');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,17 +69,40 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
const landing = new Map<number, Placed[]>();
|
||||
for (const d of all) landing.set(d.dayNumber, []);
|
||||
|
||||
/**
|
||||
* How far into a day the one before it still reaches.
|
||||
*
|
||||
* A stop stood at for twenty-four hours is not over when the date changes: the day
|
||||
* after it begins where it ends, not at nothing. Only ever a floor, and only when a
|
||||
* stay genuinely runs past midnight — an ordinary day that finishes at six in the
|
||||
* evening hands on nothing, because the night between them is not a wait, it is a
|
||||
* night.
|
||||
*/
|
||||
let carriedInto: { dayNumber: number; minute: number } | null = null;
|
||||
|
||||
for (const d of all) {
|
||||
const notBefore = carriedInto?.dayNumber === d.dayNumber ? carriedInto.minute : null;
|
||||
const routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!));
|
||||
const legs = routed.map((l) => l?.seg);
|
||||
const schedule = computeSchedule(
|
||||
d.stops.map((s) => ({
|
||||
anchor: s.time ?? s.checkInTime ?? null,
|
||||
dwellMinutes: s.dwellMinutes,
|
||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||
})),
|
||||
legs.map((l) => l?.duration),
|
||||
{ notBefore },
|
||||
);
|
||||
// Where this day's last clock lands, carried to whichever day that turns out to be.
|
||||
const ends = schedule.endsAt ?? null;
|
||||
if (ends !== null && ends > 1440) {
|
||||
carriedInto = { dayNumber: d.dayNumber + Math.floor(ends / 1440), minute: ends % 1440 };
|
||||
} else if (carriedInto && carriedInto.dayNumber <= d.dayNumber) {
|
||||
// Spent: this is the day it was pointing at.
|
||||
carriedInto = null;
|
||||
}
|
||||
// Otherwise it is left alone. A stay of several days passes over the days in the
|
||||
// middle without them having anything to say, and clearing it there would lose the
|
||||
// morning it was aimed at.
|
||||
|
||||
let day = 0;
|
||||
let previous: number | null = null;
|
||||
@@ -90,7 +113,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
if (previous !== null && clock < previous) day += 1;
|
||||
previous = clock;
|
||||
}
|
||||
const offset = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day;
|
||||
const offset = day;
|
||||
const marks = schedule.warnings.filter((w) => w.index === i);
|
||||
|
||||
const reachable = offset > 0 && numbers.has(d.dayNumber + offset);
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface RoadtripStop {
|
||||
time: string | null;
|
||||
|
||||
dwellMinutes: number | null;
|
||||
checkoutAt?: number;
|
||||
checkInTime?: string | null;
|
||||
endDay?: boolean;
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('computeSchedule', () => {
|
||||
});
|
||||
|
||||
it('handles an empty chain', () => {
|
||||
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] });
|
||||
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [], endsAt: null });
|
||||
});
|
||||
|
||||
it('treats a missing dwell as no time spent', () => {
|
||||
|
||||
@@ -133,6 +133,15 @@ export interface ScheduleEntry {
|
||||
export interface Schedule {
|
||||
entries: ScheduleEntry[];
|
||||
warnings: ScheduleWarning[];
|
||||
/**
|
||||
* Minute the last stop is left at, counted from the start of this day, so a stay
|
||||
* running past midnight comes back as more than 1440.
|
||||
*
|
||||
* The one number the next day needs. A stop stood at for twenty-four hours is not
|
||||
* over when the date changes, and the day after it cannot begin before it ends.
|
||||
* Null or absent when nothing on this day has a time at all.
|
||||
*/
|
||||
endsAt?: number | null;
|
||||
}
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
@@ -154,13 +163,24 @@ function resolveArrival(
|
||||
};
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
/**
|
||||
* @param opts.notBefore Minute of this day the first stop cannot be reached before,
|
||||
* because the day before is still running into it: a stop stood at past midnight ends
|
||||
* where it ends, and nothing can happen ahead of that. Behaves like the arrival of an
|
||||
* imaginary stop just before the first, so a stop pinned earlier keeps its clock and
|
||||
* picks up the same late finding any leg it cannot make in time would give it.
|
||||
*/
|
||||
export function computeSchedule(
|
||||
stops: ScheduleStop[],
|
||||
legSeconds: (number | undefined)[],
|
||||
opts: { notBefore?: number | null } = {},
|
||||
): Schedule {
|
||||
const warnings: ScheduleWarning[] = [];
|
||||
|
||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
||||
const anchored: boolean[] = new Array(stops.length).fill(false);
|
||||
|
||||
let cursor: number | null = null;
|
||||
let cursor: number | null = opts.notBefore ?? null;
|
||||
let dayOffset = 0;
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
@@ -231,7 +251,22 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, warnings };
|
||||
// Read off the last stop that actually has a clock: a trailing stop with no time of
|
||||
// its own carries nothing forward, and a day whose times peter out halfway should
|
||||
// hand on what it does know rather than nothing.
|
||||
let endsAt: number | null = null;
|
||||
for (let i = stops.length - 1; i >= 0; i--) {
|
||||
const raw = arrivals[i];
|
||||
if (raw === null || raw === undefined) continue;
|
||||
const arrival = raw + shift;
|
||||
endsAt =
|
||||
stops[i]!.departureAt === undefined
|
||||
? arrival + (stops[i]!.dwellMinutes ?? 0)
|
||||
: Math.max(arrival, stops[i]!.departureAt! + shift);
|
||||
break;
|
||||
}
|
||||
|
||||
return { entries, warnings, endsAt };
|
||||
}
|
||||
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
|
||||
Reference in New Issue
Block a user