mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
finish overnight stops and cross-day range warnings
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1196,7 +1196,7 @@ export const MapView = memo(function MapView({
|
||||
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
|
||||
smaller gap its shadow ran into the sidebar edge and the two read as one
|
||||
surface. */}
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<div style={{ position: 'absolute', left: leftWidth + 20, top: hasInspector ? 80 : undefined, bottom: hasInspector ? undefined : switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
|
||||
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,27 +21,6 @@ export function MapViewAuto(props: any) {
|
||||
const { t } = useTranslation()
|
||||
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards }
|
||||
const status = hazards.enabled ? <div role="status" style={props.hasInspector || props.hasDayDetail ? { top: 64 } : { bottom: 40 }} className="absolute left-1/2 z-[500] flex max-w-[calc(100%-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2 text-caption text-content shadow-md">
|
||||
{hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap font-medium">
|
||||
<AlertTriangle size={14} className="text-content-muted" aria-hidden />
|
||||
{t('roadtrip.hazards.current')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 whitespace-nowrap tabular-nums text-content-muted">
|
||||
<Clock size={12} aria-hidden />
|
||||
{new Date(hazards.feed!.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{hazards.feed!.sources.map(source => <Tooltip key={source.source} placement="top"
|
||||
label={source.status === 'ok' ? source.source : `${source.source}: ${t(`roadtrip.hazards.${source.status}`)}`}>
|
||||
<span tabIndex={0} className={`inline-flex items-center gap-1 rounded-md bg-surface-secondary px-2 py-1 font-medium ${source.status === 'ok' ? 'text-content-secondary' : 'text-warning'}`}>
|
||||
{source.source}
|
||||
{source.status !== 'ok' && <Info size={12} aria-label={t(`roadtrip.hazards.${source.status}`)} />}
|
||||
</span>
|
||||
</Tooltip>)}
|
||||
</span>
|
||||
</>}
|
||||
</div> : null
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
@@ -61,12 +40,12 @@ export function MapViewAuto(props: any) {
|
||||
// to Leaflet keeps a usable map instead of an error card.
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<><ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>{status}</>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
return <><MapView {...mapProps} />{status}</>
|
||||
return <MapView {...mapProps} />
|
||||
}
|
||||
|
||||
@@ -2299,7 +2299,8 @@ export function MapViewGL({
|
||||
does, which is why the lift is only there. */}
|
||||
<div style={{
|
||||
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
|
||||
bottom: isMobile && hasDayDetail
|
||||
top: hasInspector ? 80 : undefined,
|
||||
bottom: hasInspector ? undefined : isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
||||
}}>
|
||||
|
||||
@@ -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 />}
|
||||
<span className="min-w-0 truncate">{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}<CheckoutDeparture stop={stop} entry={entry} />
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
@@ -959,6 +959,17 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
|
||||
* 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">
|
||||
<Clock 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'
|
||||
@@ -1094,7 +1105,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' ? 'min-w-0 truncate' : 'min-w-0 break-words'}>{stop.name}</span>{stop.stopType === 'charging' && <ChargingInfo placeId={stop.placeId} compact />}
|
||||
<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>
|
||||
{/* Two halves under one border: the word says what the number means, so the
|
||||
number needs no unit of explanation beside it. */}
|
||||
@@ -1257,7 +1268,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 ? (
|
||||
{departure && spill.fromStop?.checkoutAt === undefined ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-auto shrink-0 whitespace-nowrap font-semibold leading-6 tabular-nums"
|
||||
@@ -1330,17 +1341,8 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
const dayColorsOn = useRoadtripSettings(s => !!s.roadtrip_day_colors)
|
||||
const tint = dayColorsOn ? dayColor(day.dayNumber).line : null
|
||||
const last = day.stops.length - 1
|
||||
/**
|
||||
* The findings a stop wears, minus the one the refuel band already says better.
|
||||
*
|
||||
* The range warning marks the stop somebody FINDS OUT at and counts the whole tank;
|
||||
* the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both
|
||||
* at once says the same thing twice, in the wrong order — the answer above the problem
|
||||
* — so the warning stands down wherever the band is showing.
|
||||
*/
|
||||
const showingBand = !!refuel && !loading
|
||||
const findingsFor = (i: number) =>
|
||||
day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range'))
|
||||
day.driveWarnings.filter(w => w.index === i && w.code !== 'range')
|
||||
// The running number a stop wears, with the service stops passed over — so a day with a
|
||||
// charger halfway through still counts one, two, three the way its map pins do.
|
||||
let counted = 0
|
||||
@@ -1353,7 +1355,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* `stop.ownerIndex` instead, because a stop driven onto this date through the night is
|
||||
* still stored on the day it set off from (`nightSpill.ts`).
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
const renderStopContent = (stop: RoadtripStop, i: number): React.ReactElement => {
|
||||
if (stop.automaticNight) return (
|
||||
<li key={stop.assignmentId}>
|
||||
<AutomaticDayStop stop={stop} entry={day.schedule.entries[i]} onFocus={onFocusPoint} />
|
||||
@@ -1482,6 +1484,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
* Built rather than mapped straight through, because a spill is drawn inside a block of
|
||||
* its own and a block cannot be opened halfway down a `map`.
|
||||
*/
|
||||
const renderStop = (stop: RoadtripStop, i: number): React.ReactElement => (
|
||||
<React.Fragment key={stop.assignmentId}>
|
||||
{refuel && !loading ? (day.dryPoints ?? []).filter(dry => dry.legIndex === -i - 1).map(dry => (
|
||||
<li key={`inbound-${i}`}>
|
||||
<RefuelBand dry={dry} dayId={day.dayId} refuel={refuel}
|
||||
onAsk={() => onAskRefuel?.(day.dayId, dry)}
|
||||
onAccept={onAcceptRefuel ? poi => onAcceptRefuel(day.dayId, poi, dry) : undefined} />
|
||||
</li>
|
||||
)) : null}
|
||||
{renderStopContent(stop, i)}
|
||||
</React.Fragment>
|
||||
)
|
||||
const runs: { spill: SpillMark | null; from: number; to: number }[] = []
|
||||
{
|
||||
let at = 0
|
||||
|
||||
@@ -90,13 +90,13 @@ describe('RoadtripStopPopup', () => {
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: clicking the selected kind keeps a service stop', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -275,3 +275,30 @@ describe('RoadtripStopPopup', () => {
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('editing a roadtrip stop', () => {
|
||||
it('loads the saved duration and saves without adding another stop', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ editing: { placeId: 7, stopType: 'fuel', dwellMinutes: 45 } })} {...noop} onSave={onSave} />)
|
||||
expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument()
|
||||
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 () => {
|
||||
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' }))
|
||||
await waitFor(() => expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:00', checkOut: '10:00' }))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('prefills check-in from arrival while preserving a saved manual time', () => {
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'hotel' }), arrivalTime: '15:45', overnight: { days: [{ id: 4, number: 1, date: null }, { id: 5, number: 2, date: null }], defaultEndDayId: 5 } })} {...noop} onSaveNight={onSaveNight} />)
|
||||
expect(screen.getByRole('textbox', { name: 'Check-in' })).toHaveValue('15:45')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Check-in' }), { target: { value: '16:30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '16:30', checkOut: '' })
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ 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, SERVICE_COLORS } from './roadtripModel'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
@@ -28,6 +30,8 @@ import type { RoadtripStopType } from '@trek/shared'
|
||||
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
|
||||
|
||||
export interface RoadtripStopDraft {
|
||||
editing?: { placeId: number; dwellMinutes: number; stopType: RoadtripStopType | null; accommodationId?: number; checkIn?: string; checkOut?: string }
|
||||
arrivalTime?: string | null
|
||||
poi: CorridorPoi
|
||||
dayId: number
|
||||
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
|
||||
@@ -81,15 +85,15 @@ export default function RoadtripStopPopup({
|
||||
// scheme for a bare host and drops anything that is not http(s).
|
||||
const websiteHref = safeExternalHref(draft?.poi.website)
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(draft?.editing ? draft.editing.stopType : suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30)
|
||||
// A hotel is a night by default and a campsite a pause, which is what each already
|
||||
// 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?.poi.category === 'hotel')
|
||||
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('')
|
||||
const [checkOut, setCheckOut] = useState('')
|
||||
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
|
||||
@@ -116,8 +120,10 @@ export default function RoadtripStopPopup({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
|
||||
<Modal isOpen onClose={onClose} title={t(draft.editing ? 'common.edit' : 'roadtrip.stop.addTitle')} size={overnight ? 'xl' : 'sm'}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={overnight ? "grid grid-cols-1 gap-5 sm:grid-cols-2" : ""}>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
||||
@@ -201,28 +207,15 @@ export default function RoadtripStopPopup({
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkIn}
|
||||
onChange={e => setCheckIn(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
type="time"
|
||||
value={checkOut}
|
||||
onChange={e => setCheckOut(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<CustomTimePicker value={checkOut} onChange={setCheckOut} aria-label={t('day.checkOut')} placeholder="" style={{ marginTop: 6, width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
{/* What OSM knows about the house, which is the whole of what we offer: no
|
||||
price, no availability, no link into a booking portal. TREK links to maps
|
||||
and to the place's own site, never to a vendor. */}
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{websiteHref ? (
|
||||
@@ -258,7 +251,7 @@ export default function RoadtripStopPopup({
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
setStopType(on ? null : key)
|
||||
setStopType(key)
|
||||
// Picking a kind is also picking how long it takes, until the user
|
||||
// says otherwise — a charge is not a fuel stop.
|
||||
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
|
||||
@@ -303,6 +296,12 @@ 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}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -317,7 +316,7 @@ export default function RoadtripStopPopup({
|
||||
disabled={saving}
|
||||
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t('roadtrip.poi.add')}
|
||||
{t(draft.editing ? 'common.save' : 'roadtrip.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { trivagoSearchHref, campingSearchHref } from './stayPortalLinks'
|
||||
import { copyText } from '../../utils/clipboard'
|
||||
|
||||
const HOTEL_PORTALS = [
|
||||
{ name: 'trivago', icon: '/images/portals/trivago.png', href: 'https://www.trivago.com/' },
|
||||
{ name: 'CHECK24', icon: '/images/portals/check24.png', href: 'https://hotel.check24.de/' },
|
||||
]
|
||||
const CAMPING_PORTALS = [
|
||||
{ name: 'PiNCAMP', icon: '/images/portals/pincamp.png', href: 'https://www.pincamp.com/' },
|
||||
{ name: 'Pitchup', icon: '/images/portals/pitchup.ico', href: 'https://www.pitchup.com/' },
|
||||
]
|
||||
|
||||
export default function StayPortals({ name, camping, lat, lng, arrival, departure }: { name: string; camping: boolean; lat: number; lng: number; arrival?: string | null; departure?: string | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [copyFailed, setCopyFailed] = useState(false)
|
||||
|
||||
const copy = async (): Promise<void> => {
|
||||
const didCopy = await copyText(name)
|
||||
setCopied(didCopy)
|
||||
setCopyFailed(!didCopy)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex min-w-0 flex-col gap-3 rounded-xl bg-surface-secondary p-4">
|
||||
<p className="text-body font-semibold text-content">{t('roadtrip.stay.portals')}</p>
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.stay.portalHint')}</p>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-edge bg-surface px-3 py-2">
|
||||
<span className="min-w-0 flex-1 select-text break-words text-caption text-content-secondary">{name}</span>
|
||||
<button type="button" onClick={() => void copy()} aria-label={t(copied ? 'common.copied' : 'common.copy')}
|
||||
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover hover:text-content">
|
||||
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
{copyFailed ? <p role="alert" className="text-caption text-danger">{t('common.error')}</p> : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
{(camping ? CAMPING_PORTALS : HOTEL_PORTALS).map(portal => (
|
||||
<a key={portal.name} href={portal.name === 'trivago' ? trivagoSearchHref(name, arrival, departure) : portal.name === 'PiNCAMP' || portal.name === 'Pitchup' ? campingSearchHref(portal.name, name, lat, lng, arrival, departure) : portal.href} target="_blank" rel="noopener noreferrer"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-edge bg-surface px-3 py-2.5 text-body font-medium text-content transition-colors hover:bg-surface-hover">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<img src={portal.icon} alt="" width={20} height={20} className="h-5 w-5 shrink-0 rounded object-contain" />
|
||||
{portal.name}
|
||||
</span>
|
||||
<ExternalLink size={14} className="text-content-muted" aria-hidden />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default function VisitControls({ endDay, stay }: { endDay?: EndDayControl
|
||||
className="flex min-w-0 items-center justify-between gap-2 rounded-[10px] bg-surface-hover px-3 py-2 text-start enabled:hover:bg-surface-tertiary disabled:cursor-default">
|
||||
<span className="flex items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium uppercase text-content-secondary"><Clock size={13} className="shrink-0 text-content-faint" aria-hidden />{t('roadtrip.stop.stayShort')}</span>
|
||||
{' '}<span className="flex shrink-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
|
||||
{formatDurationShort((stay.minutes ?? 0) * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
||||
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
||||
</span>
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export function trivagoSearchHref(name: string, arrival?: string | null, departure?: string | null): string {
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/
|
||||
const dates = arrival && departure && datePattern.test(arrival) && datePattern.test(departure) && departure > arrival
|
||||
? 'dr-' + arrival.replace(/-/g, '') + '-' + departure.replace(/-/g, '') + ';'
|
||||
: ''
|
||||
return 'https://www.trivago.com/de/lm/hotels-weltweit?search=' + dates + 'qs-' + encodeURIComponent(name).replace(/;/g, '%3B')
|
||||
}
|
||||
|
||||
|
||||
export function campingSearchHref(portal: 'PiNCAMP' | 'Pitchup', name: string, lat: number, lng: number, arrival?: string | null, departure?: string | null): string {
|
||||
const query = new URLSearchParams()
|
||||
const dated = arrival && departure && /^\d{4}-\d{2}-\d{2}$/.test(arrival) && /^\d{4}-\d{2}-\d{2}$/.test(departure) && departure > arrival
|
||||
if (portal === 'Pitchup') {
|
||||
query.set('lat', String(lat))
|
||||
query.set('lng', String(lng))
|
||||
query.set('placename', name)
|
||||
if (dated) {
|
||||
query.set('arrive', arrival)
|
||||
query.set('depart', departure)
|
||||
}
|
||||
return 'https://www.pitchup.com/campsites/?' + query
|
||||
}
|
||||
const latitudeSpan = 20 / 111.32
|
||||
const longitudeSpan = Math.min(180, latitudeSpan / Math.max(0.001, Math.cos(lat * Math.PI / 180)))
|
||||
query.set('viewport', [Math.max(-90, lat - latitudeSpan), Math.max(-180, lng - longitudeSpan), Math.min(90, lat + latitudeSpan), Math.min(180, lng + longitudeSpan)].map(n => n.toFixed(6)).join(','))
|
||||
query.set('title', name)
|
||||
if (dated) {
|
||||
query.set('date_from', arrival)
|
||||
query.set('date_to', departure)
|
||||
query.set('flex', 'false')
|
||||
}
|
||||
return 'https://www.pincamp.com/search?' + query
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { formatClock, parseClock } from './roadtripModel'
|
||||
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
||||
|
||||
export function stopArrival(day: RoadtripDay, index: number, alongKm: number): string | null {
|
||||
if (index === 0) return day.schedule.entries[0]?.arrival ?? null
|
||||
const departure = parseClock(day.schedule.entries[index - 1]?.departure)
|
||||
const leg = day.legs[index - 1]
|
||||
if (departure === null || !leg) return null
|
||||
const before = day.legs.slice(0, index - 1)
|
||||
if (before.some(leg => !leg)) return null
|
||||
const distanceBefore = before.reduce((total, leg) => total + leg!.distance, 0)
|
||||
const fraction = leg.distance > 0 ? Math.max(0, Math.min(1, (alongKm * 1000 - distanceBefore) / leg.distance)) : 0
|
||||
return formatClock(Math.round(departure + leg.duration / 60 * fraction))
|
||||
}
|
||||
@@ -37,13 +37,10 @@ describe('stop kinds', () => {
|
||||
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
|
||||
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
|
||||
// than interrupting the drive, so a hotel keeps its number in the chain and never
|
||||
// becomes a stop_type.
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
|
||||
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
|
||||
it('FE-STOPKIND-008: accommodation is a service stop as well as an overnight category', () => {
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBe('hotel')
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeDefined()
|
||||
expect(SERVICE_KIND_KEYS).toContain('hotel')
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
|
||||
@@ -68,7 +65,7 @@ describe('stop kinds', () => {
|
||||
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
|
||||
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
|
||||
for (const kind of STOP_KINDS) {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant' && kind.key !== 'hotel') {
|
||||
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ import type { RoadtripStopType } from '@trek/shared'
|
||||
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
|
||||
* own. The colours and the service list stay there and are read from here.
|
||||
*
|
||||
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
|
||||
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
|
||||
* ends the day rather than interrupting the drive, so it would be a category without
|
||||
* being a service stop.
|
||||
*/
|
||||
export interface StopKind {
|
||||
key: RoadtripStopType
|
||||
@@ -39,6 +35,7 @@ export interface StopKind {
|
||||
}
|
||||
|
||||
export const STOP_KINDS: StopKind[] = [
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: SERVICE_COLORS.hotel, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
|
||||
@@ -52,14 +49,6 @@ export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
|
||||
STOP_KINDS.map(k => [k.key, k]),
|
||||
)
|
||||
|
||||
/**
|
||||
* A category the corridor search offers, which is not the same question as a stop kind.
|
||||
*
|
||||
* Every stop kind is a category, but not every category is a stop kind: somewhere to
|
||||
* sleep is something the search can find, and staying there ends the day rather than
|
||||
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
|
||||
* in `day_accommodations`; it never becomes a `stop_type`.
|
||||
*/
|
||||
export interface CorridorCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
@@ -83,9 +72,9 @@ export const HOTEL_COLOR = '#2563EB'
|
||||
* table happens to end.
|
||||
*/
|
||||
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights' && k.key !== 'hotel')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: 'hotel' },
|
||||
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
]
|
||||
|
||||
@@ -718,6 +718,12 @@ describe('useRoadtripRoutes', () => {
|
||||
// Walked along the roads this day drives, not along the drawn line: 50 km down that
|
||||
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
|
||||
expect(dry.lat).toBeLessThan(BERLIN[0])
|
||||
setting({ roadtrip_range_km: 150 })
|
||||
await waitFor(() => expect(result.current.days[1].dryPoints![0].legIndex).toBe(-1))
|
||||
const inboundDry = result.current.days[1].dryPoints![0]
|
||||
expect(inboundDry).toMatchObject({ intoLegKm: 50, drivenMeters: 50000, inboundLine: [LUENEBURG, BERLIN] })
|
||||
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
|
||||
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,11 @@ 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, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { splitIntoRuns, parseClock, parseAvoid, type DriveLimits } from './roadtripModel'
|
||||
import { spillChains } from './nightSpill'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useVehicleRange } from './useVehicleRange'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Accommodation, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
||||
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -69,10 +69,16 @@ const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>)
|
||||
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
|
||||
const EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[], days: Day[]): RoadtripStop | null => {
|
||||
const p = a.place
|
||||
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
||||
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,
|
||||
@@ -80,7 +86,7 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): Roadtrip
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
time: a.assignment_time ?? p.place_time ?? null,
|
||||
time: a.assignment_time ?? p.place_time ?? stay?.check_in ?? null,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
endDay: a.end_day === true,
|
||||
legMode: a.leg_transport_mode ?? null,
|
||||
@@ -115,6 +121,7 @@ export function useRoadtripRoutes(
|
||||
*/
|
||||
viasByDay: Record<number, RoadtripVia[]> = {},
|
||||
boundaries: RoadtripDayBoundary[] = [],
|
||||
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
|
||||
): RoadtripRoutes {
|
||||
const { t } = useTranslation()
|
||||
const routeProfile = fallbackProfile || 'driving'
|
||||
@@ -197,7 +204,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))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.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
|
||||
@@ -212,7 +219,7 @@ export function useRoadtripRoutes(
|
||||
}
|
||||
})
|
||||
.filter(d => d.stops.length > 1)
|
||||
}, [days, assignments])
|
||||
}, [days, assignments, accommodations])
|
||||
|
||||
const quietDays = useMemo<QuietDay[]>(() => {
|
||||
return [...days]
|
||||
@@ -225,12 +232,12 @@ 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))
|
||||
.map(a => asStop(a, d.id, 0, accommodations, days))
|
||||
.filter((s): s is RoadtripStop => s !== null)
|
||||
.map((s, i) => ({ ...s, ownerIndex: i })),
|
||||
}))
|
||||
.filter(d => d.stops.length < 2)
|
||||
}, [days, assignments])
|
||||
}, [days, assignments, accommodations])
|
||||
|
||||
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
||||
// editing its notes must not fire a routing round.
|
||||
|
||||
@@ -269,7 +269,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
saveRoadtripLimit, roadtripSettingsLoading, storedAssignments,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls,
|
||||
stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
@@ -551,7 +551,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onMoveStopToDay={can('day_edit', trip) ? moveRoadtripStopToDay : undefined}
|
||||
onAskAlternatives={can('day_edit', trip) ? askRouteAlternatives : undefined}
|
||||
openAlternatives={routeAlternatives.open}
|
||||
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
|
||||
onEditStay={can('place_edit', trip) ? editRoadtripStay : undefined}
|
||||
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
|
||||
onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined}
|
||||
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion
|
||||
import { useFollowTrack } from '../../components/Roadtrip/useFollowTrack'
|
||||
import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternatives'
|
||||
import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays'
|
||||
import { stopArrival } from '../../components/Roadtrip/stopArrival'
|
||||
import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois'
|
||||
import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor'
|
||||
import {
|
||||
@@ -573,6 +574,7 @@ export function useTripPlanner() {
|
||||
routeProfile,
|
||||
roadtripVias.byDay,
|
||||
dayBoundaries.boundaries,
|
||||
tripAccommodations,
|
||||
)
|
||||
// Lives here rather than in the panel because the map draws what it finds.
|
||||
const roadtripCorridor = useRoadtripCorridor(roadtripRoutes)
|
||||
@@ -737,6 +739,7 @@ export function useTripPlanner() {
|
||||
if (!insert) return
|
||||
setStopDraft({
|
||||
poi: hit,
|
||||
arrivalTime: displayed ? stopArrival(displayed, roadtripInsertIndexFor(hit), hit.alongKm) : null,
|
||||
...insert,
|
||||
dayNumber: roadtripDayNumber,
|
||||
// Only for a hit somebody could sleep at, and it is what gives the popup its
|
||||
@@ -895,6 +898,16 @@ export function useTripPlanner() {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes })
|
||||
if (stopDraft.editing.accommodationId) {
|
||||
await accommodationsApi.delete(tripId, stopDraft.editing.accommodationId)
|
||||
await loadAccommodations()
|
||||
}
|
||||
updateRouteForDay(dayId)
|
||||
setStopDraft(null)
|
||||
return
|
||||
}
|
||||
const place = await tripActions.addPlace(tripId, {
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
@@ -927,19 +940,8 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore])
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
|
||||
|
||||
/**
|
||||
* Saves a corridor hit as somewhere to sleep rather than as a pause on the drive.
|
||||
*
|
||||
* Three writes and deliberately not one transaction: the place, its day, and the row in
|
||||
* day_accommodations. A hotel keeps its number in the chain — it is where the day ends,
|
||||
* not something the drive passes through — so it gets no `stop_type` and no dwell.
|
||||
*
|
||||
* The check-out day is the traveller's answer, defaulting to the day after. Both times
|
||||
* are optional and go through as empty when nobody filled them in: a hotel found on a
|
||||
* map has no idea when its reception opens, and the server stores null.
|
||||
*/
|
||||
const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: {
|
||||
endDayId: number
|
||||
checkIn: string
|
||||
@@ -948,6 +950,16 @@ export function useTripPlanner() {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null }
|
||||
if (stopDraft.editing.accommodationId) await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
|
||||
else await accommodationsApi.create(tripId, booking)
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' })
|
||||
await loadAccommodations()
|
||||
updateRouteForDay(dayId)
|
||||
setStopDraft(null)
|
||||
return
|
||||
}
|
||||
const place = await tripActions.addPlace(tripId, {
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
@@ -956,6 +968,7 @@ export function useTripPlanner() {
|
||||
website: poi.website || undefined,
|
||||
phone: poi.phone || undefined,
|
||||
osm_id: poi.osm_id,
|
||||
stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel',
|
||||
})
|
||||
if (place?.id) {
|
||||
const plan = reanchorAfterInsert(
|
||||
@@ -1435,7 +1448,7 @@ export function useTripPlanner() {
|
||||
const askRefuel = useCallback((dayId: number, dry: DryPoint & { lat: number; lng: number }) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
if (!day) return
|
||||
const line = (day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
const line = (dry.inboundLine ?? day.drivingGeometry ?? day.geometry).map(([lat, lng]) => ({ lat, lng }))
|
||||
if (line.length < 2) return
|
||||
const vehicle = roadtripSettings.roadtrip_vehicle
|
||||
const refuelTypes = refuelStopTypeFor(vehicle === 'electric' || vehicle === 'combustion' ? vehicle : null)
|
||||
@@ -1478,7 +1491,7 @@ export function useTripPlanner() {
|
||||
drivenKm += (leg.distance ?? 0) / 1000
|
||||
return poi.alongKm <= drivenKm
|
||||
})
|
||||
const at = Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1)
|
||||
const at = dry.inboundLine ? -dry.legIndex - 1 : Math.min((stationLeg >= 0 ? stationLeg : dry.legIndex) + 1, day.stops.length - 1)
|
||||
// That index counts along the CARD, and after a night drive a card is not one stored
|
||||
// day: its first stops belong to yesterday. The new stop goes in front of the one it
|
||||
// was measured against, so it is that stop's own day and position that place it —
|
||||
@@ -1534,11 +1547,20 @@ export function useTripPlanner() {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
setStopDraft(null)
|
||||
if (stopDraft.editing) {
|
||||
const place = places.find(place => place.id === stopDraft.editing?.placeId)
|
||||
if (place) {
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(resolvePoolAssignmentId(assignments, place.id))
|
||||
setShowPlaceForm(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Carries the kind and the dwell the popup had already worked out. Leaving them
|
||||
// behind is what turned a fuel stop into a numbered destination on the way to the
|
||||
// full form, silently and in every total.
|
||||
openAddPlaceFromPoi(poi, dayId, position, stop ?? null)
|
||||
}, [stopDraft, openAddPlaceFromPoi])
|
||||
}, [stopDraft, openAddPlaceFromPoi, places, assignments])
|
||||
|
||||
/**
|
||||
* A place on this trip that came from the same OSM object.
|
||||
@@ -1547,7 +1569,7 @@ export function useTripPlanner() {
|
||||
* would be the quickest way to add one petrol station twice.
|
||||
*/
|
||||
const stopDraftDuplicate = useMemo(() => {
|
||||
if (!stopDraft?.poi.osm_id) return null
|
||||
if (stopDraft?.editing || !stopDraft?.poi.osm_id) return null
|
||||
return places.find(p => p.osm_id === stopDraft.poi.osm_id)?.name ?? null
|
||||
}, [stopDraft, places])
|
||||
|
||||
@@ -1623,11 +1645,38 @@ export function useTripPlanner() {
|
||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
||||
if (roadtripActive && (isServiceStopType(place.stop_type) || tripAccommodations.some(stay => stay.place_id === place.id)) && typeof place.lat === 'number' && typeof place.lng === 'number') {
|
||||
const visitId = preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)
|
||||
const entry = Object.entries(assignments).find(([, visits]) => visits.some(visit => visit.id === visitId))
|
||||
if (entry) {
|
||||
const dayId = Number(entry[0])
|
||||
const visit = entry[1].find(visit => visit.id === visitId)!
|
||||
const stay = tripAccommodations.find(stay => stay.place_id === place.id && stay.start_day_id === dayId)
|
||||
const category = place.stop_type ?? (stay ? 'hotel' : '')
|
||||
const routedDay = roadtripRoutes.days.find(day => day.stops.some(stop => stop.assignmentId === visitId))
|
||||
const arrivalTime = routedDay?.schedule.entries[routedDay.stops.findIndex(stop => stop.assignmentId === visitId)]?.arrival ?? null
|
||||
setStopDraft({
|
||||
poi: { osm_id: place.osm_id ?? '', name: place.name, lat: place.lat, lng: place.lng, category, poi_type: category, address: place.address ?? null, website: place.website ?? null, phone: place.phone ?? null, opening_hours: null, cuisine: null, source: 'trek', offRouteKm: 0, alongKm: 0 },
|
||||
arrivalTime, dayId, dayNumber: days.find(day => day.id === dayId)?.day_number ?? 0, position: visit.order_index ?? 0,
|
||||
editing: { placeId: place.id, stopType: place.stop_type ?? (stay ? 'hotel' : null), dwellMinutes: place.duration_minutes ?? 30, accommodationId: stay?.id, checkIn: stay?.check_in ?? '', checkOut: stay?.check_out ?? '' },
|
||||
...(isOvernightCategory(category) ? { overnight: { ...overnightOptions(dayId), ...(stay ? { defaultEndDayId: stay.end_day_id } : {}) } } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setEditingPlace(place)
|
||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
||||
setPlaceFormDayId(null)
|
||||
setShowPlaceForm(true)
|
||||
}, [assignments])
|
||||
}, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days])
|
||||
|
||||
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])
|
||||
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
@@ -2065,7 +2114,7 @@ export function useTripPlanner() {
|
||||
? { active: !!endDayStop.endDay || dayBoundaries.boundaries.some(b => b.to_assignment_id === null && b.from_assignment_id === endDayStop.assignmentId), onToggle: () => setRoadtripEndDay(endDayStop) }
|
||||
: undefined
|
||||
const roadtripStay = roadtripActive && selectedPlace
|
||||
? { minutes: selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => setStayDraft({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined }
|
||||
? { minutes: endDayStop ? endDayStop.dwellMinutes : selectedPlace.duration_minutes ?? null, onEdit: can('place_edit', trip) ? () => editRoadtripStay({ placeId: selectedPlace.id, name: selectedPlace.name, minutes: selectedPlace.duration_minutes ?? null, arrival: null }) : undefined }
|
||||
: undefined
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
@@ -2128,7 +2177,7 @@ export function useTripPlanner() {
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries,
|
||||
refuel, askRefuel, acceptRefuel,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
|
||||
stayDraft, setStayDraft, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
dropPoiOnRoute,
|
||||
|
||||
@@ -79,7 +79,7 @@ export class PlacesMcp {
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'),
|
||||
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
@@ -123,7 +123,7 @@ export class PlacesMcp {
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant or sights. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'),
|
||||
stop_type: roadtripStopTypeSchema.optional().describe('Marks the place as a stop on a drive rather than a destination: fuel, charging, rest_area, campsite, restaurant, sights or hotel. A service stop is left out of the stop count for the day, so a day with a charger between four places still reads as four stops. Leave unset for an ordinary place.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
@@ -181,7 +181,7 @@ export class PlacesMcp {
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
|
||||
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'),
|
||||
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'),
|
||||
fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
@@ -452,7 +452,7 @@ export class PlacesMcp {
|
||||
phone: z.string().max(50).optional(),
|
||||
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'),
|
||||
description: z.string().max(2000).optional(),
|
||||
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'),
|
||||
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant, sights or hotel. Pass null to turn a service stop back into an ordinary place.'),
|
||||
fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
splitIntoRuns,
|
||||
spillChains,
|
||||
dayWindow,
|
||||
parseClock,
|
||||
effectiveRangeKm,
|
||||
parseAvoid,
|
||||
type RoadtripStop,
|
||||
@@ -29,6 +30,9 @@ interface StoredDay {
|
||||
default_transport_mode: string | null;
|
||||
}
|
||||
interface VisitRow {
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
checkout_day: number | null;
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
@@ -66,8 +70,10 @@ export class RoadtripPlanService {
|
||||
const visits = this.db.all<VisitRow>(
|
||||
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
|
||||
COALESCE(a.assignment_time, p.place_time) AS time, p.duration_minutes, a.end_day,
|
||||
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent
|
||||
a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, stay.check_in, stay.check_out, checkout.day_number AS checkout_day
|
||||
FROM day_assignments a JOIN days d ON d.id = a.day_id JOIN places p ON p.id = a.place_id
|
||||
LEFT JOIN day_accommodations stay ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1)
|
||||
LEFT JOIN days checkout ON checkout.id = stay.end_day_id
|
||||
WHERE d.trip_id = ? ORDER BY d.day_number, a.order_index, a.created_at`,
|
||||
tripId,
|
||||
);
|
||||
@@ -107,7 +113,8 @@ export class RoadtripPlanService {
|
||||
name: v.name,
|
||||
lat: v.lat!,
|
||||
lng: v.lng!,
|
||||
time: v.time,
|
||||
time: v.time ?? v.check_in ?? null,
|
||||
...(v.checkout_day != null && parseClock(v.check_out) !== null ? { checkoutAt: v.checkout_day * 1440 + parseClock(v.check_out)! } : {}),
|
||||
dwellMinutes: v.duration_minutes,
|
||||
endDay: v.end_day === 1,
|
||||
legMode: v.leg_transport_mode,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'العثور على إقامة',
|
||||
'roadtrip.stay.portalHint': 'تحقق من الوجهة والتواريخ والضيوف في الموقع. ابحث يدوياً في CHECK24.',
|
||||
'roadtrip.charging.available': 'متاح',
|
||||
'roadtrip.charging.unknown': 'لا تتوفر بيانات موثوقة',
|
||||
'roadtrip.charging.stale': 'الحالة قديمة',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.charging.available': "disponíveis",
|
||||
'roadtrip.charging.unknown': "Sem dados confiáveis",
|
||||
'roadtrip.charging.stale': "Status desatualizado",
|
||||
'roadtrip.charging.prices': "Tarifas publicadas",
|
||||
'roadtrip.charging.note': "Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.",
|
||||
'roadtrip.charging.coverage': "Dados abertos; cobertura varia por região e operador.",
|
||||
'roadtrip.stay.portals': 'Encontrar hospedagem',
|
||||
'roadtrip.stay.portalHint': 'Confira destino, datas e hóspedes no portal. No CHECK24, pesquise manualmente.',
|
||||
'roadtrip.charging.available': 'disponíveis',
|
||||
'roadtrip.charging.unknown': 'Sem dados confiáveis',
|
||||
'roadtrip.charging.stale': 'Status desatualizado',
|
||||
'roadtrip.charging.prices': 'Tarifas publicadas',
|
||||
'roadtrip.charging.note':
|
||||
'Tarifa publicada; impostos, condições e taxas extras podem se aplicar. O preço do seu cartão pode variar.',
|
||||
'roadtrip.charging.coverage': 'Dados abertos; cobertura varia por região e operador.',
|
||||
'roadtrip.import.title': 'Importar rota do Google Maps',
|
||||
'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.',
|
||||
'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Troba allotjament',
|
||||
'roadtrip.stay.portalHint': 'Comprova la destinació, les dates i els hostes al portal. A CHECK24, cerca manualment.',
|
||||
'roadtrip.charging.available': 'disponibles',
|
||||
'roadtrip.charging.unknown': 'No hi ha dades fiables',
|
||||
'roadtrip.charging.stale': 'Estat desactualitzat',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Najít ubytování',
|
||||
'roadtrip.stay.portalHint': 'Na portálu ověřte cíl, termín a hosty. Na CHECK24 hledejte ručně.',
|
||||
'roadtrip.charging.available': 'volné',
|
||||
'roadtrip.charging.unknown': 'Spolehlivá data nejsou dostupná',
|
||||
'roadtrip.charging.stale': 'Zastaralý stav',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Unterkunft finden',
|
||||
'roadtrip.stay.portalHint': 'Ziel, Daten und Gäste im Portal prüfen. Bei CHECK24 manuell suchen.',
|
||||
'roadtrip.charging.available': 'frei',
|
||||
'roadtrip.charging.unknown': 'Keine verlässlichen Daten verfügbar',
|
||||
'roadtrip.charging.stale': 'Status veraltet',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Find accommodation',
|
||||
'roadtrip.stay.portalHint': 'Check destination, dates and guests on the portal. CHECK24 requires a manual search.',
|
||||
'roadtrip.charging.available': 'available',
|
||||
'roadtrip.charging.unknown': 'No reliable data available',
|
||||
'roadtrip.charging.stale': 'Status outdated',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Buscar alojamiento',
|
||||
'roadtrip.stay.portalHint':
|
||||
'Comprueba el destino, las fechas y los huéspedes en el portal. En CHECK24, busca manualmente.',
|
||||
'roadtrip.charging.available': 'disponibles',
|
||||
'roadtrip.charging.unknown': 'No hay datos fiables',
|
||||
'roadtrip.charging.stale': 'Estado desactualizado',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Trouver un hébergement',
|
||||
'roadtrip.stay.portalHint':
|
||||
'Vérifiez la destination, les dates et les voyageurs sur le portail. Recherche manuelle sur CHECK24.',
|
||||
'roadtrip.charging.available': 'disponibles',
|
||||
'roadtrip.charging.unknown': 'Aucune donnée fiable disponible',
|
||||
'roadtrip.charging.stale': 'État périmé',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Βρείτε κατάλυμα',
|
||||
'roadtrip.stay.portalHint':
|
||||
'Ελέγξτε προορισμό, ημερομηνίες και επισκέπτες στην πύλη. Στο CHECK24 αναζητήστε χειροκίνητα.',
|
||||
'roadtrip.charging.available': 'διαθέσιμα',
|
||||
'roadtrip.charging.unknown': 'Δεν υπάρχουν αξιόπιστα δεδομένα',
|
||||
'roadtrip.charging.stale': 'Παρωχημένη κατάσταση',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Szállás keresése',
|
||||
'roadtrip.stay.portalHint':
|
||||
'Ellenőrizd az úti célt, dátumokat és vendégeket a portálon. A CHECK24 oldalon keress kézzel.',
|
||||
'roadtrip.charging.available': 'szabad',
|
||||
'roadtrip.charging.unknown': 'Nincs megbízható adat',
|
||||
'roadtrip.charging.stale': 'Elavult állapot',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Cari penginapan',
|
||||
'roadtrip.stay.portalHint': 'Periksa tujuan, tanggal, dan tamu di portal. Cari secara manual di CHECK24.',
|
||||
'roadtrip.charging.available': 'tersedia',
|
||||
'roadtrip.charging.unknown': 'Tidak ada data terpercaya',
|
||||
'roadtrip.charging.stale': 'Status kedaluwarsa',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Trova un alloggio',
|
||||
'roadtrip.stay.portalHint': 'Verifica destinazione, date e ospiti sul portale. Su CHECK24 cerca manualmente.',
|
||||
'roadtrip.charging.available': 'disponibili',
|
||||
'roadtrip.charging.unknown': 'Nessun dato affidabile',
|
||||
'roadtrip.charging.stale': 'Stato non aggiornato',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': '宿泊施設を探す',
|
||||
'roadtrip.stay.portalHint':
|
||||
'予約サイトで目的地、日付、宿泊人数を確認してください。CHECK24では手動で検索してください。',
|
||||
'roadtrip.charging.available': '空き',
|
||||
'roadtrip.charging.unknown': '信頼できるデータがありません',
|
||||
'roadtrip.charging.stale': '古い状態情報',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': '숙소 찾기',
|
||||
'roadtrip.stay.portalHint': '포털에서 목적지, 날짜, 인원을 확인하세요. CHECK24에서는 직접 검색하세요.',
|
||||
'roadtrip.charging.available': '이용 가능',
|
||||
'roadtrip.charging.unknown': '신뢰할 수 있는 데이터 없음',
|
||||
'roadtrip.charging.stale': '오래된 상태',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Accommodatie zoeken',
|
||||
'roadtrip.stay.portalHint': 'Controleer bestemming, datums en gasten op het portaal. Zoek handmatig op CHECK24.',
|
||||
'roadtrip.charging.available': 'beschikbaar',
|
||||
'roadtrip.charging.unknown': 'Geen betrouwbare gegevens',
|
||||
'roadtrip.charging.stale': 'Status verouderd',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Znajdź nocleg',
|
||||
'roadtrip.stay.portalHint': 'Sprawdź cel, daty i gości na portalu. W CHECK24 wyszukaj ręcznie.',
|
||||
'roadtrip.charging.available': 'wolne',
|
||||
'roadtrip.charging.unknown': 'Brak wiarygodnych danych',
|
||||
'roadtrip.charging.stale': 'Nieaktualny status',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Найти жильё',
|
||||
'roadtrip.stay.portalHint': 'Проверьте место, даты и гостей на портале. В CHECK24 ищите вручную.',
|
||||
'roadtrip.charging.available': 'свободно',
|
||||
'roadtrip.charging.unknown': 'Нет надёжных данных',
|
||||
'roadtrip.charging.stale': 'Статус устарел',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Hitta boende',
|
||||
'roadtrip.stay.portalHint': 'Kontrollera resmål, datum och gäster på portalen. Sök manuellt på CHECK24.',
|
||||
'roadtrip.charging.available': 'lediga',
|
||||
'roadtrip.charging.unknown': 'Inga tillförlitliga uppgifter',
|
||||
'roadtrip.charging.stale': 'Inaktuell status',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Konaklama bul',
|
||||
'roadtrip.stay.portalHint':
|
||||
'Portalda varış yerini, tarihleri ve konukları kontrol edin. CHECK24 üzerinde elle arayın.',
|
||||
'roadtrip.charging.available': 'boş',
|
||||
'roadtrip.charging.unknown': 'Güvenilir veri yok',
|
||||
'roadtrip.charging.stale': 'Durum güncel değil',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Знайти житло',
|
||||
'roadtrip.stay.portalHint': 'Перевірте місце, дати та гостей на порталі. У CHECK24 шукайте вручну.',
|
||||
'roadtrip.charging.available': 'вільні',
|
||||
'roadtrip.charging.unknown': 'Немає надійних даних',
|
||||
'roadtrip.charging.stale': 'Статус застарів',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': 'Tìm chỗ ở',
|
||||
'roadtrip.stay.portalHint': 'Kiểm tra điểm đến, ngày và khách trên cổng đặt chỗ. Tìm thủ công trên CHECK24.',
|
||||
'roadtrip.charging.available': 'còn trống',
|
||||
'roadtrip.charging.unknown': 'Không có dữ liệu đáng tin cậy',
|
||||
'roadtrip.charging.stale': 'Trạng thái đã cũ',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': '尋找住宿',
|
||||
'roadtrip.stay.portalHint': '請在平台核對目的地、日期和入住人數。CHECK24需手動搜尋。',
|
||||
'roadtrip.charging.available': '可用',
|
||||
'roadtrip.charging.unknown': '暫無可靠資料',
|
||||
'roadtrip.charging.stale': '狀態已過時',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const roadtrip: TranslationStrings = {
|
||||
'roadtrip.stay.portals': '查找住宿',
|
||||
'roadtrip.stay.portalHint': '请在平台核对目的地、日期和入住人数。CHECK24需手动搜索。',
|
||||
'roadtrip.charging.available': '可用',
|
||||
'roadtrip.charging.unknown': '暂无可靠数据',
|
||||
'roadtrip.charging.stale': '状态已过时',
|
||||
|
||||
@@ -29,23 +29,26 @@ export const hexColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}
|
||||
* against — the escaping in the marker builders stops being the only thing
|
||||
* standing between the database and the DOM.
|
||||
*/
|
||||
export const placeImageUrlSchema = z.string().max(2048).refine(
|
||||
v =>
|
||||
v.startsWith('/uploads/')
|
||||
|| v.startsWith('/api/maps/place-photo/')
|
||||
|| /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v)
|
||||
|| /^https:\/\//i.test(v),
|
||||
{ message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' },
|
||||
);
|
||||
export const placeImageUrlSchema = z
|
||||
.string()
|
||||
.max(2048)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.startsWith('/uploads/') ||
|
||||
v.startsWith('/api/maps/place-photo/') ||
|
||||
/^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v) ||
|
||||
/^https:\/\//i.test(v),
|
||||
{ message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' },
|
||||
);
|
||||
|
||||
/**
|
||||
* A place's homepage. It reaches window.open() on the client, where a
|
||||
* javascript: value would run in this origin rather than opening a page.
|
||||
*/
|
||||
export const placeWebsiteSchema = z.string().max(500).refine(
|
||||
v => /^https?:\/\//i.test(v),
|
||||
{ message: 'must be an http or https URL' },
|
||||
);
|
||||
export const placeWebsiteSchema = z
|
||||
.string()
|
||||
.max(500)
|
||||
.refine((v) => /^https?:\/\//i.test(v), { message: 'must be an http or https URL' });
|
||||
|
||||
/**
|
||||
* Embedded category as returned on a place — a trimmed projection of the
|
||||
@@ -91,7 +94,15 @@ export type PlaceRatingVote = z.infer<typeof placeRatingVoteSchema>;
|
||||
* to the column, where `isServiceStopType` then read it as false and the stop quietly
|
||||
* counted as a destination.
|
||||
*/
|
||||
export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']);
|
||||
export const roadtripStopTypeSchema = z.enum([
|
||||
'fuel',
|
||||
'charging',
|
||||
'rest_area',
|
||||
'campsite',
|
||||
'restaurant',
|
||||
'sights',
|
||||
'hotel',
|
||||
]);
|
||||
export type RoadtripStopType = z.infer<typeof roadtripStopTypeSchema>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from './planning-types';
|
||||
import {
|
||||
computeSchedule,
|
||||
parseClock,
|
||||
deriveDriveWarnings,
|
||||
refuelsRange,
|
||||
isServiceStopType,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type DriveLimits,
|
||||
type VehicleKind,
|
||||
} from './roadtripModel';
|
||||
import { splitScheduledDays } from './splitScheduledDays';
|
||||
|
||||
const stopKey = (s: RoadtripStop): string =>
|
||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
|
||||
@@ -85,11 +87,15 @@ export function assembleRoadtrip({
|
||||
...d,
|
||||
spills: [],
|
||||
schedule: computeSchedule(
|
||||
d.stops.map((s) => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
|
||||
d.stops.map((s) => ({
|
||||
anchor: s.time,
|
||||
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),
|
||||
),
|
||||
}))
|
||||
: chains;
|
||||
: splitScheduledDays(chains, [...plan, ...quietDays], storedLegFor);
|
||||
const legFor = automaticSchedule ? timed.legFor : storedLegFor;
|
||||
|
||||
const lines: [number, number][][] = [];
|
||||
@@ -150,42 +156,62 @@ export function assembleRoadtrip({
|
||||
legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0) + inbound.reduce((sum, l) => sum + (l?.duration ?? 0), 0);
|
||||
const schedule = chain.schedule;
|
||||
const legVias = routed.map((l) => l?.vias ?? []);
|
||||
const stops = chain.stops.map((s) => {
|
||||
const stops = chain.stops.map((s, index) => {
|
||||
const snap = s.automaticNight ? undefined : allSnaps[stopKey(s)];
|
||||
const line = spurFor(snap);
|
||||
if (line) accessLines.push({ line, meters: snap!.meters, stopKey: stopKey(s) });
|
||||
return { ...s, offRoadMeters: line ? snap!.meters : null };
|
||||
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 };
|
||||
});
|
||||
|
||||
const inboundKm =
|
||||
inbound
|
||||
.filter((l) => l && (l.mode === undefined || l.mode === 'driving'))
|
||||
.reduce((sum, l) => sum + (l?.distance ?? 0), 0) / 1000;
|
||||
const startKm = carryKm === null ? null : carryKm + inboundKm;
|
||||
const drive = deriveDriveWarnings(
|
||||
legs,
|
||||
|
||||
stops.map((s) => refuelsRange(s.stopType, vehicleKind)),
|
||||
limits,
|
||||
startKm,
|
||||
|
||||
stops.map((s) => s.fillPercent),
|
||||
);
|
||||
carryKm = drive.carryKm;
|
||||
|
||||
const drivingLine = routed
|
||||
.filter((l) => l && l.seg.mode !== undefined && l.seg.mode === 'driving')
|
||||
.flatMap((l) => l?.line ?? []);
|
||||
const dryPoints = drive.emptyAt
|
||||
.map((dry) => {
|
||||
const at = pointAtMeters(
|
||||
drivingLine.map(([lat, lng]) => ({ lat, lng })),
|
||||
dry.drivenMeters,
|
||||
);
|
||||
return at ? { ...dry, lat: at.lat, lng: at.lng } : null;
|
||||
})
|
||||
.filter((d): d is DryPoint & { lat: number; lng: number } => d !== null);
|
||||
|
||||
const drive = { warnings: [] as ReturnType<typeof deriveDriveWarnings>['warnings'], day: null as ReturnType<typeof deriveDriveWarnings>['day'] };
|
||||
const dryPoints: (DryPoint & { lat: number; lng: number })[] = [];
|
||||
const drivingLine: [number, number][] = [];
|
||||
let drivenMeters = 0;
|
||||
let drivingSeconds = 0;
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const incoming = inboundAt.get(i);
|
||||
if (incoming?.seg) {
|
||||
const incomingDrive = deriveDriveWarnings([incoming.seg], [false, false], limits, carryKm);
|
||||
carryKm = incomingDrive.carryKm;
|
||||
drive.warnings.push(...incomingDrive.warnings.map(w => ({ ...w, index: i })));
|
||||
for (const dry of incomingDrive.emptyAt) {
|
||||
const at = pointAtMeters(incoming.line.map(([lat, lng]) => ({ lat, lng })), dry.drivenMeters);
|
||||
if (at) dryPoints.push({ ...dry, legIndex: -i - 1, inboundLine: incoming.line, lat: at.lat, lng: at.lng });
|
||||
}
|
||||
if (!incoming.seg.mode || incoming.seg.mode === 'driving') drivingSeconds += incoming.seg.duration ?? 0;
|
||||
}
|
||||
const leg = routed[i];
|
||||
const outgoing = deriveDriveWarnings(
|
||||
i < stops.length - 1 ? [leg?.seg] : [],
|
||||
[refuelsRange(stops[i]!.stopType, vehicleKind), false],
|
||||
limits,
|
||||
carryKm,
|
||||
[stops[i]!.fillPercent],
|
||||
);
|
||||
carryKm = outgoing.carryKm;
|
||||
drive.warnings.push(...outgoing.warnings.map(w => ({ ...w, index: i + 1 })));
|
||||
for (const dry of outgoing.emptyAt) {
|
||||
const at = pointAtMeters((leg?.line ?? []).map(([lat, lng]) => ({ lat, lng })), dry.intoLegKm * 1000);
|
||||
if (at) dryPoints.push({ ...dry, legIndex: i, drivenMeters: drivenMeters + dry.intoLegKm * 1000, lat: at.lat, lng: at.lng });
|
||||
}
|
||||
if (leg && (!leg.seg.mode || leg.seg.mode === 'driving')) {
|
||||
drivingLine.push(...leg.line);
|
||||
drivenMeters += leg.seg.distance ?? 0;
|
||||
drivingSeconds += leg.seg.duration ?? 0;
|
||||
}
|
||||
}
|
||||
const drivingMinutes = Math.round(drivingSeconds / 60);
|
||||
if (limits.dayMinutes && drivingMinutes > limits.dayMinutes) {
|
||||
drive.day = { code: 'dayDriving', minutes: drivingMinutes, limitMinutes: limits.dayMinutes };
|
||||
}
|
||||
const drivingGeometry = drivingLine.length === geometry.length ? geometry : drivingLine;
|
||||
out.push({
|
||||
automaticSchedule,
|
||||
@@ -208,7 +234,7 @@ export function assembleRoadtrip({
|
||||
dayWarning: drive.day,
|
||||
});
|
||||
}
|
||||
const drives = out.filter((d) => d.stops.length > 1 || d.stops.some((s) => s.automaticNight));
|
||||
const drives = out.filter((d) => d.stops.length > 1 || !!d.spills?.length || d.stops.some((s) => s.automaticNight));
|
||||
const originalStops = [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).flatMap((day) => day.stops);
|
||||
const boundaryPath = originalStops.slice(0, -1).flatMap((from, position) => {
|
||||
const to = originalStops[position + 1]!;
|
||||
@@ -246,7 +272,7 @@ export function assembleRoadtrip({
|
||||
),
|
||||
|
||||
quietDays: out
|
||||
.filter((d) => d.stops.length < 2 && !d.stops.some((s) => s.automaticNight))
|
||||
.filter((d) => d.stops.length < 2 && !d.spills?.length && !d.stops.some((s) => s.automaticNight))
|
||||
.map((d) => ({ dayId: d.dayId, dayNumber: d.dayNumber, date: d.date, title: d.title, stops: d.stops })),
|
||||
loading,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { continueSchedule } from './continueSchedule';
|
||||
import type { RoadtripStop } from './planning-types';
|
||||
import type { Schedule } from './roadtripModel';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const stops = [0, 60, 60, 60].map((dwellMinutes) => ({ dwellMinutes }) as RoadtripStop);
|
||||
const schedule = (): Schedule => ({
|
||||
entries: [
|
||||
{ arrival: '18:15', departure: '18:15', dayOffset: 0, anchored: false },
|
||||
...[1, 2, 3].map(() => ({ arrival: null, departure: null, dayOffset: 0, anchored: false })),
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
describe('continuing after a moved day group', () => {
|
||||
it('carries Munich departure into the original day stops and over midnight', () => {
|
||||
const original = schedule();
|
||||
const completed = continueSchedule(stops, original, [237 * 60, 198 * 60, 206 * 60], 2);
|
||||
expect(completed.entries.map((entry) => entry.arrival)).toEqual(['18:15', '22:12', '02:30', '06:56']);
|
||||
expect(completed.entries[2]!.dayOffset).toBe(1);
|
||||
expect(original.entries[1]!.arrival).toBeNull();
|
||||
});
|
||||
it('keeps manual times and stops at an unknown route duration', () => {
|
||||
const original = schedule();
|
||||
original.entries[2] = { arrival: '07:00', departure: '08:00', dayOffset: 1, anchored: true };
|
||||
const completed = continueSchedule(stops, original, [undefined, 3600, 3600], 2);
|
||||
expect(completed.entries[1]!.arrival).toBeNull();
|
||||
expect(completed.entries[2]).toEqual(original.entries[2]);
|
||||
expect(completed.entries[3]!.arrival).toBe('09:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RoadtripStop } from './planning-types';
|
||||
import { formatClock, parseClock, type Schedule } from './roadtripModel';
|
||||
|
||||
export function continueSchedule(
|
||||
stops: RoadtripStop[],
|
||||
schedule: Schedule,
|
||||
legs: (number | undefined)[],
|
||||
dayNumber: number,
|
||||
): Schedule {
|
||||
const entries = schedule.entries.map((entry) => ({ ...entry }));
|
||||
const warnings = [...schedule.warnings];
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const entry = entries[i]!;
|
||||
if (entry.arrival !== null) continue;
|
||||
const previous = entries[i - 1]!;
|
||||
const departure = parseClock(previous.departure);
|
||||
const duration = legs[i - 1];
|
||||
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);
|
||||
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);
|
||||
entry.arrival = formatClock(arrivalAt);
|
||||
entry.departure = formatClock(leaveAt);
|
||||
entry.dayOffset = Math.floor(arrivalAt / 1440);
|
||||
if (entry.dayOffset > previous.dayOffset) warnings.push({ index: i, code: 'overnight' });
|
||||
}
|
||||
return { entries, warnings };
|
||||
}
|
||||
@@ -365,3 +365,28 @@ describe('daily travel window', () => {
|
||||
expect(planned.chains[1]!.schedule.entries[1]!.arrival).toBe('10:00');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,7 @@ export function planDayWindow(
|
||||
name: labels.end,
|
||||
time: null,
|
||||
dwellMinutes: 0,
|
||||
checkoutAt: undefined,
|
||||
stopType: null,
|
||||
fillPercent: null,
|
||||
automaticNight: { phase: 'end', fromDayNumber: number, position, manual: targets.has(number) },
|
||||
@@ -275,6 +276,26 @@ 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);
|
||||
|
||||
@@ -73,7 +73,11 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
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, dwellMinutes: s.dwellMinutes })),
|
||||
d.stops.map((s) => ({
|
||||
anchor: s.time,
|
||||
dwellMinutes: s.dwellMinutes,
|
||||
departureAt: s.checkoutAt === undefined ? undefined : s.checkoutAt - d.dayNumber * 1440,
|
||||
})),
|
||||
legs.map((l) => l?.duration),
|
||||
);
|
||||
|
||||
@@ -86,7 +90,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
||||
if (previous !== null && clock < previous) day += 1;
|
||||
previous = clock;
|
||||
}
|
||||
const offset = day;
|
||||
const offset = d.stops.some((s) => s.checkoutAt !== undefined) ? (entry?.dayOffset ?? day) : day;
|
||||
const marks = schedule.warnings.filter((w) => w.index === i);
|
||||
|
||||
const reachable = offset > 0 && numbers.has(d.dayNumber + offset);
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface RoadtripStop {
|
||||
time: string | null;
|
||||
|
||||
dwellMinutes: number | null;
|
||||
checkoutAt?: number;
|
||||
endDay?: boolean;
|
||||
|
||||
legMode: string | null;
|
||||
|
||||
@@ -791,3 +791,34 @@ describe('deriveDriveWarnings — filling only part way', () => {
|
||||
expect(out.emptyAt).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout without daily travel times', () => {
|
||||
it('keeps the car at the hotel until the next day', () => {
|
||||
const schedule = computeSchedule(
|
||||
[
|
||||
{ anchor: '16:00', dwellMinutes: 30, departureAt: 1440 + 720 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[3600],
|
||||
);
|
||||
expect(schedule.entries[0]!.departure).toBe('12:00');
|
||||
expect(schedule.entries[1]!.arrival).toBe('13:00');
|
||||
expect(schedule.entries[1]!.dayOffset).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses checkout as departure even without an arrival or daily start', () => {
|
||||
const schedule = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
{ anchor: null, dwellMinutes: 60, departureAt: 1440 + 480 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[3600, 7200],
|
||||
);
|
||||
expect(schedule.entries[0]!.arrival).toBeNull();
|
||||
expect(schedule.entries[1]!.arrival).toBeNull();
|
||||
expect(schedule.entries[1]!.departure).toBe('08:00');
|
||||
expect(schedule.entries[2]!.arrival).toBe('10:00');
|
||||
expect(schedule.entries[2]!.dayOffset).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { RouteAvoidClass } from './planning-types';
|
||||
|
||||
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const;
|
||||
export const SERVICE_STOP_TYPES = [
|
||||
'fuel',
|
||||
'charging',
|
||||
'rest_area',
|
||||
'campsite',
|
||||
'restaurant',
|
||||
'sights',
|
||||
'hotel',
|
||||
] as const;
|
||||
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
fuel: '#E8590C',
|
||||
@@ -9,6 +17,7 @@ export const SERVICE_COLORS: Record<string, string> = {
|
||||
campsite: '#16A34A',
|
||||
restaurant: '#EF4444',
|
||||
sights: '#EC4899',
|
||||
hotel: '#2563EB',
|
||||
};
|
||||
|
||||
export function serviceColor(stopType: string | null | undefined): string {
|
||||
@@ -48,6 +57,7 @@ export function formatClock(minutes: number): string {
|
||||
}
|
||||
|
||||
export interface ScheduleStop {
|
||||
departureAt?: number;
|
||||
anchor: string | null;
|
||||
|
||||
dwellMinutes: number | null;
|
||||
@@ -143,7 +153,11 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset);
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy });
|
||||
|
||||
if (arrival === null) continue;
|
||||
if (arrival === null) {
|
||||
const leg = legSeconds[i];
|
||||
if (stop.departureAt !== undefined && leg !== undefined) cursor = stop.departureAt + Math.round(leg / 60);
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = Math.floor(arrival / DAY_MINUTES);
|
||||
if (offset > dayOffset) dayOffset = offset;
|
||||
@@ -152,11 +166,16 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
anchored[i]! = anchor !== null;
|
||||
|
||||
const leg = legSeconds[i];
|
||||
cursor = leg === undefined ? null : arrival + (stop.dwellMinutes ?? 0) + Math.round(leg / 60);
|
||||
cursor =
|
||||
leg === undefined
|
||||
? null
|
||||
: (stop.departureAt === undefined ? arrival + (stop.dwellMinutes ?? 0) : Math.max(arrival, stop.departureAt)) +
|
||||
Math.round(leg / 60);
|
||||
}
|
||||
|
||||
const firstKnown = arrivals.findIndex((a) => a !== null);
|
||||
for (let i = firstKnown - 1; i >= 0; i--) {
|
||||
if (stops[i]!.departureAt !== undefined) break;
|
||||
const leg = legSeconds[i];
|
||||
const next = arrivals[i + 1]!;
|
||||
if (leg === undefined || next === null) break;
|
||||
@@ -171,7 +190,12 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const raw = arrivals[i]!;
|
||||
if (raw === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 });
|
||||
entries.push({
|
||||
arrival: null,
|
||||
departure: stops[i]!.departureAt === undefined ? null : formatClock(stops[i]!.departureAt!),
|
||||
anchored: false,
|
||||
dayOffset: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const arrival = raw + shift;
|
||||
@@ -181,7 +205,11 @@ export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | und
|
||||
lastOffset = offset;
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(arrival + (stops[i]!.dwellMinutes ?? 0)),
|
||||
departure: formatClock(
|
||||
stops[i]!.departureAt === undefined
|
||||
? arrival + (stops[i]!.dwellMinutes ?? 0)
|
||||
: Math.max(arrival, stops[i]!.departureAt! + shift),
|
||||
),
|
||||
anchored: anchored[i]!,
|
||||
dayOffset: offset,
|
||||
});
|
||||
@@ -217,6 +245,8 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
}
|
||||
|
||||
export interface DryPoint {
|
||||
inboundLine?: [number, number][];
|
||||
|
||||
legIndex: number;
|
||||
|
||||
intoLegKm: number;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SpillChain } from './nightSpill';
|
||||
import type { RoadtripStop, RoutedLeg } from './planning-types';
|
||||
import { splitScheduledDays } from './splitScheduledDays';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const chain = (): SpillChain => ({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
date: '2026-09-12',
|
||||
title: null,
|
||||
stops: [0, 1, 2].map((i) => ({ placeId: i + 1, dwellMinutes: 60, time: null }) as RoadtripStop),
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '22:12', departure: '23:12', anchored: false, dayOffset: 0 },
|
||||
...[1, 2].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })),
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
spills: [],
|
||||
});
|
||||
const lookup = (): RoutedLeg =>
|
||||
({
|
||||
seg: { duration: 198 * 60 },
|
||||
line: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
vias: [],
|
||||
}) as RoutedLeg;
|
||||
describe('scheduled day overflow', () => {
|
||||
it('creates a display day for the stops after midnight', () => {
|
||||
const source = chain();
|
||||
const days = splitScheduledDays([source], [source], lookup);
|
||||
expect(days.map((day) => day.dayNumber)).toEqual([2, 3]);
|
||||
expect(days[1]!.dayId).toBe(-1000000003);
|
||||
expect(days[1]!.date).toBe('2026-09-13');
|
||||
expect(days[1]!.schedule.entries.map((entry) => entry.arrival)).toEqual(['02:30', '06:48']);
|
||||
expect(days[1]!.schedule.entries.every((entry) => entry.dayOffset === 0)).toBe(true);
|
||||
expect(days[1]!.spills[0]).toMatchObject({ fromDayNumber: 2, at: 0, count: 2, departure: '23:12' });
|
||||
expect(source.stops).toHaveLength(3);
|
||||
});
|
||||
it('uses an existing empty day and keeps an earlier spill group', () => {
|
||||
const source = chain();
|
||||
source.spills = [
|
||||
{ at: 0, count: 1, fromDayNumber: 1, departure: '18:00', leg: undefined, line: [], fromStop: undefined },
|
||||
];
|
||||
const days = splitScheduledDays(
|
||||
[source],
|
||||
[source, { dayId: 99, dayNumber: 3, date: '2026-09-13', title: 'Rome', stops: [] }],
|
||||
lookup,
|
||||
);
|
||||
expect(days[1]!.dayId).toBe(99);
|
||||
expect(days[0]!.spills[0]!.fromDayNumber).toBe(1);
|
||||
expect(days[1]!.spills[0]!.fromDayNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { continueSchedule } from './continueSchedule';
|
||||
import type { SpillChain, SpillMark } from './nightSpill';
|
||||
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||
|
||||
export function splitScheduledDays(
|
||||
chains: SpillChain[],
|
||||
days: PlanDay[],
|
||||
legFor: (from: RoadtripStop, to: RoadtripStop) => RoutedLeg | undefined,
|
||||
): SpillChain[] {
|
||||
const grouped = new Map<number, SpillChain>();
|
||||
for (const chain of chains) {
|
||||
const schedule = continueSchedule(
|
||||
chain.stops,
|
||||
chain.schedule,
|
||||
chain.stops.slice(0, -1).map((stop, i) => legFor(stop, chain.stops[i + 1]!)?.seg.duration),
|
||||
chain.dayNumber,
|
||||
);
|
||||
chain.stops.forEach((stop, i) => {
|
||||
const entry = schedule.entries[i]!;
|
||||
const offset = Math.max(0, entry.dayOffset);
|
||||
const number = chain.dayNumber + offset;
|
||||
let target = grouped.get(number);
|
||||
if (!target) {
|
||||
const stored = days.find((day) => day.dayNumber === number);
|
||||
let date = stored?.date ?? null;
|
||||
if (!date && chain.date) {
|
||||
const shifted = new Date(chain.date + 'T12:00:00Z');
|
||||
shifted.setUTCDate(shifted.getUTCDate() + offset);
|
||||
date = shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
target = {
|
||||
dayId: stored?.dayId ?? -1000000000 - number,
|
||||
dayNumber: number,
|
||||
date,
|
||||
title: stored?.title ?? null,
|
||||
stops: [],
|
||||
schedule: { entries: [], warnings: [] },
|
||||
spills: [],
|
||||
};
|
||||
grouped.set(number, target);
|
||||
}
|
||||
const index = target.stops.length;
|
||||
target.stops.push(stop);
|
||||
target.schedule.entries.push({ ...entry, dayOffset: 0 });
|
||||
target.schedule.warnings.push(
|
||||
...schedule.warnings
|
||||
.filter((warning) => warning.index === i && !(offset && warning.code === 'overnight'))
|
||||
.map((warning) => ({ ...warning, index })),
|
||||
);
|
||||
const inherited = chain.spills.find((spill) => i >= spill.at && i < spill.at + spill.count);
|
||||
const fromDayNumber = offset ? chain.dayNumber : inherited?.fromDayNumber;
|
||||
if (fromDayNumber !== undefined) {
|
||||
const last = target.spills[target.spills.length - 1];
|
||||
if (last && last.fromDayNumber === fromDayNumber && last.at + last.count === index) last.count += 1;
|
||||
else {
|
||||
const from = i > 0 ? chain.stops[i - 1] : inherited?.fromStop;
|
||||
const leg = from ? legFor(from, stop) : undefined;
|
||||
const mark: SpillMark = offset
|
||||
? {
|
||||
at: index,
|
||||
count: 1,
|
||||
fromDayNumber,
|
||||
departure: schedule.entries[i - 1]?.departure ?? inherited?.departure ?? null,
|
||||
fromStop: from,
|
||||
leg: leg?.seg,
|
||||
line: leg?.line ?? [],
|
||||
}
|
||||
: { ...inherited!, at: index, count: 1 };
|
||||
target.spills.push(mark);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return [...grouped.values()].sort((a, b) => a.dayNumber - b.dayNumber);
|
||||
}
|
||||
@@ -119,3 +119,19 @@ Roadtrip charging stops show compact availability and published energy-price bad
|
||||
> **AI / MCP:** get_roadtrip_charging_info reads the same availability, tariff components, freshness and source information for a saved charging stop.
|
||||
|
||||
GDACS warning popups show the current episode score when supplied, falling back to the overall event score. The compact scale indicates GDACS humanitarian impact, not whether roads are passable.
|
||||
|
||||
### Accommodation portals in Roadtrip
|
||||
|
||||
The Add as a stop dialog offers trivago and CHECK24 for hotels, or PiNCAMP and Pitchup for campsites. The portal opens in a new tab without adding a stop or making a booking. trivago receives the search text; PiNCAMP receives a geographic area extending roughly 20 km from the stop; Pitchup receives the stop coordinates. All three receive the trip dates when both days have dates and departure is after arrival. Review the destination, dates and guest count on the portal. CHECK24 requires a manual search. Check-in and check-out use the shared TREK time picker.
|
||||
|
||||
All Looking for categories, including accommodation, are added as Roadtrip service stops. In Roadtrip mode, Edit in a planned place's details opens the stop dialog with its saved duration and overnight dates and times. Saving updates the existing stop; More details still opens the full place editor. The shared stop-type contract also accepts hotel through MCP.
|
||||
|
||||
A booked check-out time controls departure from an overnight stop on its check-out day. STAY is calculated from arrival until check-out, including multiple nights, and updates with the route. Explicit arrival times remain authoritative. Clicking STAY for a booking with check-out opens the overnight editor. Browser planning and MCP use the same scheduling logic.
|
||||
|
||||
The overnight dialog prefills check-in from the calculated arrival when available; existing manual check-in values take priority. Before adding a corridor result, the suggested arrival is estimated along the current routed leg. Without any known arrival, checkout still anchors the onward drive, but STAY remains unknown instead of displaying the old default duration.
|
||||
|
||||
Ordinary places retain the full place editor in Roadtrip mode. The compact stop editor is used for service stops and accommodation. When visits move into another day, their calculated departure continues into that day's following stops, including midnight offsets.
|
||||
|
||||
Stops reached after midnight move to the next calculated Roadtrip day with a FROM DAY label. This also creates a display day when no saved day exists yet and retains arrival times for a day containing only one moved stop.
|
||||
|
||||
Range warnings also locate an empty battery or tank on connections between days. The warning offers reachable stations on that connection and inserts a selected stop before its destination.
|
||||
|
||||
Reference in New Issue
Block a user