mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +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 })),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user