mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): leave a stop at its End and show the stay that makes
A visit's End could be edited and was shown in road trip mode, but the schedule never read it: the drive always left a stop after its stay, so a traveller who knew when they had to leave but not how long they would stand there had no way to say so. The End is now the time the drive leaves. RoadtripStop.leaveAt is filled from the visit's End (or its place's) on the client by useRoadtripRoutes and on the server by roadtrip-plan.service, so calculate_roadtrip and get_roadtrip_context agree with the planner, and computeSchedule takes it as the departure it already knew how to hold. With Start and End both set the stay is the time between them. With daily travel times on, the stay is the time until then, and an End before the window start starts the day early the way a Start does. Before the first fixed time the chain works backwards through an End as it does through a pinned Start. An End is read against the drive: the last passing of that clock time counts as missed when the drive went past it on the road or earlier the same day, anything else is the next one, so a drive across midnight neither waits a whole day nor pretends to be on time. A drive that gets there after the End leaves on arrival and files a missedLeave finding, shown beside the late badge in the rail and on the phone. The rail's STAY badge, the inspector's Stay tile, the phone stop sheet and the phone chain show the stay the End makes, with an "until 14:00" hint. It is read off the schedule and never written to duration_minutes: the stay belongs to the place, the End to one visit, and a stored copy would go stale as soon as the arrival moved. For such a stop the stay dialog, desktop and phone, shows that stay read only and offers to remove the End, through the visit's time route with an offline path (assignmentRepo.setTimes, setAssignmentTimes). While the road trip is on, the End field in both place forms says the drive leaves at that time. The dead checkoutAt in the plan service is gone. New strings in all 23 locales.
This commit is contained in:
@@ -585,6 +585,20 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-086: on a road trip the End says the drive leaves at it', () => {
|
||||||
|
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' });
|
||||||
|
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} roadtripActive />);
|
||||||
|
expect(screen.getByText('On the road trip, the drive leaves at this time.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-087: in Days the End stays a plain label', () => {
|
||||||
|
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '14:00' });
|
||||||
|
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
||||||
|
expect(screen.queryByText('On the road trip, the drive leaves at this time.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||||
// Build an assignment whose place has end_time before place_time
|
// Build an assignment whose place has end_time before place_time
|
||||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface PlaceFormModalProps {
|
|||||||
* of the drive it belongs on. Absent, nothing about the form changes.
|
* of the drive it belongs on. Absent, nothing about the form changes.
|
||||||
*/
|
*/
|
||||||
serviceStop?: ServiceStopMode | null
|
serviceStop?: ServiceStopMode | null
|
||||||
|
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
|
||||||
|
roadtripActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1181,6 +1183,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
assignmentId={assignmentId}
|
assignmentId={assignmentId}
|
||||||
dayAssignments={dayAssignments}
|
dayAssignments={dayAssignments}
|
||||||
hasTimeError={hasTimeError}
|
hasTimeError={hasTimeError}
|
||||||
|
endIsLeave={!!props.roadtripActive}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1270,10 +1273,13 @@ interface TimeSectionProps {
|
|||||||
assignmentId: number | null
|
assignmentId: number | null
|
||||||
dayAssignments: Assignment[]
|
dayAssignments: Assignment[]
|
||||||
hasTimeError: boolean
|
hasTimeError: boolean
|
||||||
|
/** On a road trip the End is when the drive leaves, which the field says. In Days it
|
||||||
|
* stays the plain label it has always been: nothing is scheduled off it there. */
|
||||||
|
endIsLeave: boolean
|
||||||
t: (key: string, params?: Record<string, string | number>) => string
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) {
|
||||||
|
|
||||||
const collisions = useMemo(() => {
|
const collisions = useMemo(() => {
|
||||||
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
||||||
@@ -1311,6 +1317,7 @@ function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTime
|
|||||||
value={form.end_time}
|
value={form.end_time}
|
||||||
onChange={v => handleChange('end_time', v)}
|
onChange={v => handleChange('end_time', v)}
|
||||||
/>
|
/>
|
||||||
|
{endIsLeave && <p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.endIsLeave')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasTimeError && (
|
{hasTimeError && (
|
||||||
|
|||||||
@@ -322,7 +322,10 @@ describe('RoadtripSidebar', () => {
|
|||||||
const add = within(chain).getByText('+')
|
const add = within(chain).getByText('+')
|
||||||
|
|
||||||
fireEvent.click(add)
|
fireEvent.click(add)
|
||||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
// The visit rides along with the place, so the dialog can reach its end time.
|
||||||
|
expect(onEditStay).toHaveBeenCalledWith({
|
||||||
|
placeId: 20, name: 'Berlin', minutes: null, arrival: null, departure: null, leaveAt: null, missedBy: null, assignmentId: 2, dayId: 1,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
|
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
|
||||||
@@ -342,6 +345,53 @@ describe('RoadtripSidebar', () => {
|
|||||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-SIDEBAR-049: a stop left at a set time shows the stay that makes, and until when', () => {
|
||||||
|
// The stay the place carries is 30 min; the End at two is what the drive keeps to.
|
||||||
|
const onEditStay = vi.fn()
|
||||||
|
const stops = [
|
||||||
|
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||||
|
stop({ assignmentId: 2, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }),
|
||||||
|
]
|
||||||
|
const schedule = {
|
||||||
|
entries: [
|
||||||
|
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
|
||||||
|
{ arrival: '10:00', departure: '14:00', anchored: false, dayOffset: 0 },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
}
|
||||||
|
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} onEditStay={onEditStay} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('4 h')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('until 14:00')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('30 min')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 4 h until 14:00' }))
|
||||||
|
expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', departure: '14:00' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-SIDEBAR-050: a stop reached after the time it is left at says so, beside a late arrival', () => {
|
||||||
|
const stops = [
|
||||||
|
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||||
|
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', leaveAt: '14:00', time: '14:00' }),
|
||||||
|
]
|
||||||
|
const schedule = {
|
||||||
|
entries: stops.map(() => ({ arrival: '14:30', departure: '14:30', anchored: true, dayOffset: 0 })),
|
||||||
|
warnings: [
|
||||||
|
{ index: 1, code: 'late' as const, minutes: 30 },
|
||||||
|
{ index: 1, code: 'missedLeave' as const, minutes: 30 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const onEditStay = vi.fn()
|
||||||
|
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} onEditStay={onEditStay} />)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Arrives 30 min after the time you set')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Arrives 30 min after the time you set to leave')).toBeInTheDocument()
|
||||||
|
// Left the moment it is reached: a zero, until the time it was meant to go.
|
||||||
|
expect(screen.getByText('0 min')).toBeInTheDocument()
|
||||||
|
// The dialog is handed the schedule's own finding, so it can say the same.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Time at this stop: 0 min until 14:00' }))
|
||||||
|
expect(onEditStay).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', missedBy: 30 }))
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||||
const stops = [
|
const stops = [
|
||||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { FS } from './typeScale'
|
|||||||
import type { RouteSegment } from '../../types'
|
import type { RouteSegment } from '../../types'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import AutomaticDayStop from './AutomaticDayStop'
|
import AutomaticDayStop from './AutomaticDayStop'
|
||||||
|
import type { StayDraft } from './RoadtripStayModal'
|
||||||
|
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
|
||||||
|
|
||||||
interface RoadtripSidebarProps {
|
interface RoadtripSidebarProps {
|
||||||
onFocusPoint?: (lat: number, lng: number) => void
|
onFocusPoint?: (lat: number, lng: number) => void
|
||||||
@@ -62,7 +64,7 @@ interface RoadtripSidebarProps {
|
|||||||
* Opens the dialog for how long a stop takes. Absent leaves every stay read-only —
|
* Opens the dialog for how long a stop takes. Absent leaves every stay read-only —
|
||||||
* which is also what a viewer sees.
|
* which is also what a viewer sees.
|
||||||
*/
|
*/
|
||||||
onEditStay?: (stop: { placeId: number; name: string; minutes: number | null; arrival: string | null }) => void
|
onEditStay?: (stop: StayDraft) => void
|
||||||
/**
|
/**
|
||||||
* Turns a stop into a pause on the drive, or back into a destination.
|
* Turns a stop into a pause on the drive, or back into a destination.
|
||||||
*
|
*
|
||||||
@@ -280,10 +282,17 @@ function FillBadge({ percent, own, onEdit }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null {
|
/**
|
||||||
|
* How long the stop takes. A stop the traveller leaves at a set time is stood at until
|
||||||
|
* then, so it reads the stay the schedule made of that and says until when.
|
||||||
|
*/
|
||||||
|
function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const text = minutes ? formatDurationShort(minutes * 60) : null
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
if (!text && !onEdit) return null
|
const shown = shownStay(stay)
|
||||||
|
const text = shown === null ? null : formatDurationShort(shown * 60)
|
||||||
|
const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
|
||||||
|
if (!text && !until && !onEdit) return null
|
||||||
|
|
||||||
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
|
||||||
const label = (
|
const label = (
|
||||||
@@ -301,7 +310,8 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
|
|||||||
}`}
|
}`}
|
||||||
style={{ fontSize: FS.label }}
|
style={{ fontSize: FS.label }}
|
||||||
>
|
>
|
||||||
{text ?? '+'}
|
{text ?? (until ? null : '+')}
|
||||||
|
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,7 +333,7 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onEdit()
|
onEdit()
|
||||||
}}
|
}}
|
||||||
aria-label={text ? `${t('roadtrip.stop.stay')}: ${text}` : t('roadtrip.stay.add')}
|
aria-label={text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add')}
|
||||||
className={`${shell} cursor-pointer transition-colors hover:border-content-faint`}
|
className={`${shell} cursor-pointer transition-colors hover:border-content-faint`}
|
||||||
>
|
>
|
||||||
{label}{value}
|
{label}{value}
|
||||||
@@ -749,9 +759,10 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
|||||||
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
|
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
|
||||||
stop: RoadtripStop
|
stop: RoadtripStop
|
||||||
entry: ScheduleEntry | undefined
|
entry: ScheduleEntry | undefined
|
||||||
/** How late the drive reaches a time pinned on this pause, the same finding a numbered
|
/** How late the drive reaches a time pinned on this pause, or the time it was set to
|
||||||
* stop shows. A fuel or charging halt can carry a pinned time like anything else. */
|
* be left at: the same findings a numbered stop shows. A fuel or charging halt can
|
||||||
late: ScheduleWarning | undefined
|
* carry a pinned time like anything else. */
|
||||||
|
late: ScheduleWarning[]
|
||||||
/** Findings about the drive that ARRIVES here. A charging halt is a stop like any other
|
/** Findings about the drive that ARRIVES here. A charging halt is a stop like any other
|
||||||
* as far as the tank is concerned, so it carries them the same way a numbered one does. */
|
* as far as the tank is concerned, so it carries them the same way a numbered one does. */
|
||||||
driveFindings?: ScheduleWarning[]
|
driveFindings?: ScheduleWarning[]
|
||||||
@@ -834,7 +845,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
|||||||
<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 />}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
|
||||||
{refuelsRange(stop.stopType, vehicleKind) ? (
|
{refuelsRange(stop.stopType, vehicleKind) ? (
|
||||||
<FillBadge
|
<FillBadge
|
||||||
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
||||||
@@ -844,7 +855,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
|||||||
) : null}
|
) : null}
|
||||||
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
||||||
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||||
<LateBadge late={late} />
|
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||||
@@ -985,7 +996,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
|||||||
/** Position within the day — the same count the map badges its markers with. */
|
/** Position within the day — the same count the map badges its markers with. */
|
||||||
number: number
|
number: number
|
||||||
entry: ScheduleEntry | undefined
|
entry: ScheduleEntry | undefined
|
||||||
late: ScheduleWarning | undefined
|
/** Arriving after a pinned time, or after the time this stop was set to be left at. */
|
||||||
|
late: ScheduleWarning[]
|
||||||
/** Findings about the drive LEAVING this stop, when the limits are set and it goes over. */
|
/** Findings about the drive LEAVING this stop, when the limits are set and it goes over. */
|
||||||
driveFindings?: ScheduleWarning[]
|
driveFindings?: ScheduleWarning[]
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -1089,7 +1101,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
|||||||
enough to change the plan. The dashed line on the map already says there is
|
enough to change the plan. The dashed line on the map already says there is
|
||||||
one; the number is for luggage, a gate, a track a hire car should not be on. */}
|
one; the number is for luggage, a gate, a track a hire car should not be on. */}
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
|
||||||
{refuelsRange(stop.stopType, vehicleKind) ? (
|
{refuelsRange(stop.stopType, vehicleKind) ? (
|
||||||
<FillBadge
|
<FillBadge
|
||||||
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
||||||
@@ -1099,7 +1111,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
|||||||
) : null}
|
) : null}
|
||||||
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
||||||
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||||
<LateBadge late={late} />
|
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
{entry?.arrival ? <Arrival entry={entry} /> : null}
|
||||||
@@ -1118,10 +1130,9 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
|||||||
* its own: they are all answers to "what does this stop cost", and a warning on its own row
|
* its own: they are all answers to "what does this stop cost", and a warning on its own row
|
||||||
* pushed every following stop down for a finding that fits in a pill.
|
* pushed every following stop down for a finding that fits in a pill.
|
||||||
*/
|
*/
|
||||||
function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.ReactElement | null {
|
function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (!late) return null
|
const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
|
||||||
const label = t('roadtrip.warn.late', { minutes: late.minutes ?? 0 })
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
<span
|
<span
|
||||||
@@ -1348,8 +1359,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
|||||||
const service = isServiceStopType(stop.stopType)
|
const service = isServiceStopType(stop.stopType)
|
||||||
if (!service) counted += 1
|
if (!service) counted += 1
|
||||||
// Every finding at this index is read on its own. Taking the first match let an
|
// Every finding at this index is read on its own. Taking the first match let an
|
||||||
// overnight crossing swallow the "you arrive late" flag without a trace.
|
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
|
||||||
const marks = day.schedule.warnings.filter(w => w.index === i)
|
// can be late for the time it is reached and the time it is left at both at once.
|
||||||
|
const lateness = day.schedule.warnings.filter(w => w.index === i && (w.code === 'late' || w.code === 'missedLeave'))
|
||||||
const ownDay = stop.ownerDayId
|
const ownDay = stop.ownerDayId
|
||||||
const ownIndex = stop.ownerIndex
|
const ownIndex = stop.ownerIndex
|
||||||
return (
|
return (
|
||||||
@@ -1388,11 +1400,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
|||||||
<ServiceStop
|
<ServiceStop
|
||||||
stop={stop}
|
stop={stop}
|
||||||
entry={day.schedule.entries[i]}
|
entry={day.schedule.entries[i]}
|
||||||
late={marks.find(w => w.code === 'late')}
|
late={lateness}
|
||||||
driveFindings={findingsFor(i)}
|
driveFindings={findingsFor(i)}
|
||||||
selected={selectedAssignmentId === stop.assignmentId}
|
selected={selectedAssignmentId === stop.assignmentId}
|
||||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||||
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
|
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
|
||||||
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
|
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
|
||||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -1401,7 +1413,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
|||||||
stop={stop}
|
stop={stop}
|
||||||
number={counted}
|
number={counted}
|
||||||
entry={day.schedule.entries[i]}
|
entry={day.schedule.entries[i]}
|
||||||
late={marks.find(w => w.code === 'late')}
|
late={lateness}
|
||||||
driveFindings={findingsFor(i)}
|
driveFindings={findingsFor(i)}
|
||||||
selected={selectedAssignmentId === stop.assignmentId}
|
selected={selectedAssignmentId === stop.assignmentId}
|
||||||
continues={i < last}
|
continues={i < last}
|
||||||
@@ -1409,7 +1421,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
|||||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||||
onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined}
|
onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined}
|
||||||
canMove={{ up: i > 0, down: i < last }}
|
canMove={{ up: i > 0, down: i < last }}
|
||||||
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
|
onEditStay={onEditStay ? () => onEditStay(stayDraftOf(stop, day.schedule.entries[i], missedLeaveOf(day, i))) : undefined}
|
||||||
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
|
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
|
||||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import React from 'react'
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
|
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
|
* FE-STAYMODAL-001..022: how long the traveller stays at one stop.
|
||||||
*
|
*
|
||||||
* The value is `places.duration_minutes`, which the schedule has read for years
|
* The value is `places.duration_minutes`, which the schedule has read for years
|
||||||
* and nothing could write. Two things are worth pinning: that clearing sends a
|
* and nothing could write. Two things are worth pinning: that clearing sends a
|
||||||
@@ -158,3 +162,93 @@ describe('RoadtripStayModal', () => {
|
|||||||
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('RoadtripStayModal for a stop left at a set time', () => {
|
||||||
|
// The visit's End is when the drive leaves it, so there is no length to choose: the
|
||||||
|
// stay is what the time leaves of it. What the dialog offers instead is taking the end
|
||||||
|
// time off, after which the stay is a choice again.
|
||||||
|
const leaving = { minutes: 30, arrival: '10:00', departure: '14:00', leaveAt: '14:00', assignmentId: 102, dayId: 11 }
|
||||||
|
let setAssignmentTimes: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
|
||||||
|
setAssignmentTimes = vi.fn(async () => undefined)
|
||||||
|
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: { id: 4, user_id: 1 },
|
||||||
|
assignments: { '11': [{ id: 102, day_id: 11, place_id: 9, order_index: 0, assignment_time: null, assignment_end_time: '14:00' }] },
|
||||||
|
})
|
||||||
|
useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-015: shows the stay the time makes and when the drive leaves, with nothing to slide', () => {
|
||||||
|
open(leaving)
|
||||||
|
expect(screen.getByText('4 h')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('This visit has an end time, so the drive leaves at 14:00.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('10:00')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('14:00')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /^save$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-016: taking the end time off writes it through the visit and brings the slider back', async () => {
|
||||||
|
const { onSave } = open(leaving)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
|
||||||
|
await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null }))
|
||||||
|
// The stay the place carries is where the slider starts again.
|
||||||
|
await waitFor(() => expect(slider().value).toBe('30'))
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-017: a traveller who may not change the day can read it but not remove it', () => {
|
||||||
|
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
|
||||||
|
open(leaving)
|
||||||
|
expect(screen.getByText('4 h')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-018: without an arrival it can only say until when', () => {
|
||||||
|
open({ ...leaving, arrival: null, departure: '14:00' })
|
||||||
|
expect(screen.getByText('until 14:00')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('10:00')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-019: closing changes nothing', () => {
|
||||||
|
const { onClose, onSave } = open(leaving)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
expect(onSave).not.toHaveBeenCalled()
|
||||||
|
expect(setAssignmentTimes).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-020: reached after the time, it says so and shows the departure the drive actually makes', () => {
|
||||||
|
open({ ...leaving, arrival: '14:30', departure: '14:30', missedBy: 30 })
|
||||||
|
expect(screen.getByText('0 min')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('14:30')).toHaveLength(2)
|
||||||
|
// One departure, not two: the promise of 14:00 is gone, the finding takes its place.
|
||||||
|
expect(screen.getByText('Arrives 30 min after the time you set to leave')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('This visit has an end time, so the drive leaves at 14:00.')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-021: travel hours over before the time say so, with no length and no departure', () => {
|
||||||
|
// The window shut at 18:00, the End is 20:00: the drive goes on the next morning.
|
||||||
|
open({ ...leaving, arrival: '10:30', departure: '18:00', leaveAt: '20:00' })
|
||||||
|
expect(screen.getByText('until 20:00')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('The travel day ends before 20:00, so the drive goes on the next morning.')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('18:00')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('7 h 30 min')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-STAYMODAL-022: an End the place carries too goes with the removal, or the slider would not be the truth', async () => {
|
||||||
|
const updatePlace = vi.fn(async () => undefined)
|
||||||
|
seedStore(useTripStore, { places: [{ id: 9, name: 'Rasthof Dammer Berge', end_time: '14:00' }] })
|
||||||
|
useTripStore.setState({ updatePlace } as never)
|
||||||
|
open(leaving)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
|
||||||
|
await waitFor(() => expect(updatePlace).toHaveBeenCalledWith(4, 9, { end_time: null }))
|
||||||
|
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: null, end_time: null })
|
||||||
|
await waitFor(() => expect(slider().value).toBe('30'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { useTranslation } from '../../i18n/TranslationContext'
|
import { useTranslation } from '../../i18n/TranslationContext'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
||||||
import { formatClockTime } from '../../utils/formatters'
|
import { formatClockTime } from '../../utils/formatters'
|
||||||
|
import { useLeaveMode, type LeaveMode } from './useLeaveMode'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lengths a stop usually takes, so the common answer is one tap.
|
* The lengths a stop usually takes, so the common answer is one tap.
|
||||||
@@ -33,6 +34,15 @@ export interface StayDraft {
|
|||||||
minutes: number | null
|
minutes: number | null
|
||||||
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
||||||
arrival?: string | null
|
arrival?: string | null
|
||||||
|
/** When the schedule has it leave again. */
|
||||||
|
departure?: string | null
|
||||||
|
/** The time the visit is set to be left at, which makes the stay rather than taking one. */
|
||||||
|
leaveAt?: string | null
|
||||||
|
/** How many minutes after that time the drive gets here, when the schedule found it cannot make it. */
|
||||||
|
missedBy?: number | null
|
||||||
|
assignmentId?: number
|
||||||
|
/** The day the visit is stored on. */
|
||||||
|
dayId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoadtripStayModalProps {
|
interface RoadtripStayModalProps {
|
||||||
@@ -75,6 +85,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
|||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const [minutes, setMinutes] = useState(0)
|
const [minutes, setMinutes] = useState(0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const leave = useLeaveMode(stop)
|
||||||
|
|
||||||
// Reopened on a different stop, so it starts from that stop's own value rather than
|
// Reopened on a different stop, so it starts from that stop's own value rather than
|
||||||
// from whatever the last one was left on.
|
// from whatever the last one was left on.
|
||||||
@@ -97,6 +108,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
|||||||
}, [stop?.arrival, minutes, is12h])
|
}, [stop?.arrival, minutes, is12h])
|
||||||
|
|
||||||
if (!stop) return null
|
if (!stop) return null
|
||||||
|
if (leave.until) return <LeaveTime stop={stop} leave={leave} until={leave.until} is12h={is12h} onClose={onClose} />
|
||||||
|
|
||||||
const commit = async (value: number) => {
|
const commit = async (value: number) => {
|
||||||
if (saving) return
|
if (saving) return
|
||||||
@@ -163,31 +175,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
|||||||
|
|
||||||
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
|
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
|
||||||
decides it — so the arrow shows the one end this dialog does move. */}
|
decides it — so the arrow shows the one end this dialog does move. */}
|
||||||
{times ? (
|
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.leaveCarry} /> : null}
|
||||||
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
|
||||||
<span className="flex flex-col items-center gap-0.5">
|
|
||||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
|
||||||
{t('roadtrip.stay.arrive')}
|
|
||||||
</span>
|
|
||||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
|
|
||||||
</span>
|
|
||||||
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
|
|
||||||
<span className="flex flex-col items-center gap-0.5">
|
|
||||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
|
||||||
{t('roadtrip.stay.leave')}
|
|
||||||
</span>
|
|
||||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
|
|
||||||
{times.leave}
|
|
||||||
{times.leaveCarry > 0 ? (
|
|
||||||
<span className="ms-0.5">
|
|
||||||
{`+${times.leaveCarry}`}
|
|
||||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{PRESETS.map(value => (
|
{PRESETS.map(value => (
|
||||||
@@ -237,3 +225,106 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
|||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog for a stop the traveller leaves at a set time.
|
||||||
|
*
|
||||||
|
* Nothing to choose: the stay is whatever is left between the arrival and that time, so
|
||||||
|
* it is shown rather than offered. The one thing to do here is take the end time off the
|
||||||
|
* visit, and the ordinary dialog takes over the moment that lands.
|
||||||
|
*
|
||||||
|
* The sentence under the number says what the drive actually does. Reached too late it
|
||||||
|
* leaves on arrival, which is the case this dialog gets opened to fix; with the travel
|
||||||
|
* hours over first it goes on the next morning. Saying "leaves at 14:00" over a box that
|
||||||
|
* reads 14:30 gave two departures for one stop.
|
||||||
|
*/
|
||||||
|
function LeaveTime({ stop, leave, until: leaveAt, is12h, onClose }: {
|
||||||
|
stop: StayDraft
|
||||||
|
leave: LeaveMode
|
||||||
|
until: string
|
||||||
|
is12h: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const until = formatClockTime(leaveAt, is12h)
|
||||||
|
const arrive = stop.arrival ? formatClockTime(stop.arrival, is12h) : null
|
||||||
|
const departure = stop.departure ? formatClockTime(stop.departure, is12h) : until
|
||||||
|
return (
|
||||||
|
<Modal isOpen onClose={onClose} size="sm" title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hourglass size={15} className="text-content-faint" aria-hidden />
|
||||||
|
{t('roadtrip.stop.stay')}
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
|
||||||
|
<span className="text-center text-title font-semibold tabular-nums tracking-tight text-content">
|
||||||
|
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
|
||||||
|
</span>
|
||||||
|
{leave.missedBy !== null ? (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-center text-body font-medium text-warning">
|
||||||
|
<AlertTriangle size={14} className="shrink-0" aria-hidden />
|
||||||
|
{t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-body text-content-secondary">
|
||||||
|
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* The day ending first means the row's departure is only where it stops for the
|
||||||
|
night, not a time it leaves. */}
|
||||||
|
{arrive && !leave.dayEndsFirst ? <ArriveLeave arrive={arrive} leave={departure} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-2">
|
||||||
|
{leave.remove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={leave.removing}
|
||||||
|
onClick={() => void leave.remove?.()}
|
||||||
|
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('roadtrip.stay.clearLeave')}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrival, arrow, departure: the two ends of the stay, with the day the second lands on. */
|
||||||
|
function ArriveLeave({ arrive, leave, carry = 0 }: { arrive: string; leave: string; carry?: number }): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||||
|
<span className="flex flex-col items-center gap-0.5">
|
||||||
|
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||||
|
{t('roadtrip.stay.arrive')}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{arrive}</span>
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
|
||||||
|
<span className="flex flex-col items-center gap-0.5">
|
||||||
|
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||||
|
{t('roadtrip.stay.leave')}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
|
||||||
|
{leave}
|
||||||
|
{carry > 0 ? (
|
||||||
|
<span className="ms-0.5">
|
||||||
|
{`+${carry}`}
|
||||||
|
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,14 @@ it('keeps stay editing when daily travel times are off', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled()
|
||||||
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reads the stay a set leave time makes, and says until when', () => {
|
||||||
|
render(<VisitControls stay={{ minutes: 240, until: '14:00', onEdit: vi.fn() }} />)
|
||||||
|
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 4 h roadtrip.stay.until' })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says only until when while the drive has not timed the arrival', () => {
|
||||||
|
render(<VisitControls stay={{ minutes: null, until: '14:00' }} />)
|
||||||
|
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled()
|
||||||
|
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { Clock, Pencil } from 'lucide-react'
|
import { Clock, Pencil } from 'lucide-react'
|
||||||
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
|
import EndDayControl, { type EndDayControlProps } from './EndDayControl'
|
||||||
import { useTranslation } from '../../i18n/TranslationContext'
|
import { useTranslation } from '../../i18n/TranslationContext'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { formatClockTime } from '../../utils/formatters'
|
||||||
import { formatDurationShort } from './roadtripModel'
|
import { formatDurationShort } from './roadtripModel'
|
||||||
|
|
||||||
export interface RoadtripStayControl {
|
export interface RoadtripStayControl {
|
||||||
minutes: number | null
|
minutes: number | null
|
||||||
|
/** The time the visit is set to be left at, which is what makes its stay. */
|
||||||
|
until?: string | null
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
|
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const until = stay?.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null
|
||||||
return (
|
return (
|
||||||
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
{endDay && <EndDayControl {...endDay} />}
|
{endDay && <EndDayControl {...endDay} />}
|
||||||
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
|
{stay && <button type="button" onClick={stay.onEdit} disabled={!stay.onEdit}
|
||||||
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">
|
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 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">
|
{' '}<span className="flex min-w-0 items-center gap-1.5 text-[length:calc(12px*var(--fs-scale-body,1))] font-medium text-content-secondary">
|
||||||
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
{stay.minutes === null ? (until ? null : '+') : formatDurationShort(stay.minutes * 60)}
|
||||||
|
{until ? <>{' '}<span className="truncate text-content-faint">{until}</span></> : null}
|
||||||
|
{stay.onEdit && <Pencil size={12} className="shrink-0 text-content-faint" aria-hidden />}
|
||||||
</span>
|
</span>
|
||||||
</button>}
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||||
|
|
||||||
// FE-RTROW-001 to FE-RTROW-039
|
// FE-RTROW-001 to FE-RTROW-044
|
||||||
|
|
||||||
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
|
||||||
return {
|
return {
|
||||||
@@ -277,6 +277,23 @@ describe('pickWarning', () => {
|
|||||||
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
|
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
|
||||||
expect(pickWarning([])).toBeNull()
|
expect(pickWarning([])).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-RTROW-043: missing the time a stop is left at ranks right after being late for it', () => {
|
||||||
|
const missed: ScheduleWarning = { index: 0, code: 'missedLeave', minutes: 30 }
|
||||||
|
expect(pickWarning([{ index: 0, code: 'range', sinceKm: 700 }, missed])).toBe(missed)
|
||||||
|
expect(pickWarning([missed, { index: 0, code: 'late', minutes: 5 }])?.code).toBe('late')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a stop left at a set time', () => {
|
||||||
|
it('FE-RTROW-044: its row carries the stay the time makes, not the one the place has', () => {
|
||||||
|
const d = day([stop('A'), stop('Bremen', { dwellMinutes: 30, leaveAt: '14:00' })], {
|
||||||
|
schedule: { entries: [entry('09:00'), entry('10:00', { departure: '14:00' })], warnings: [] },
|
||||||
|
})
|
||||||
|
const [, bremen] = stopRows(roadtripRows(d))
|
||||||
|
expect(bremen.dwellMinutes).toBe(240)
|
||||||
|
expect(bremen.departure).toBe('14:00')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('stageOf', () => {
|
describe('stageOf', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||||
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
|
||||||
|
import { readStay } from './stayReading'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A road trip day, flattened into the rows a screen draws, with no React in sight.
|
* A road trip day, flattened into the rows a screen draws, with no React in sight.
|
||||||
@@ -32,12 +33,13 @@ export interface StopRow {
|
|||||||
entry: ScheduleEntry | undefined
|
entry: ScheduleEntry | undefined
|
||||||
/** The clock this row shows on the right: when the traveller gets there. */
|
/** The clock this row shows on the right: when the traveller gets there. */
|
||||||
time: string | null
|
time: string | null
|
||||||
/** When they leave again, which is arrival plus the stay. Null without a schedule. */
|
/** When they leave again: arrival plus the stay, or the time set to leave. Null without a schedule. */
|
||||||
departure: string | null
|
departure: string | null
|
||||||
/** True when a person pinned that time, which is what the pin in front of it means. */
|
/** True when a person pinned that time, which is what the pin in front of it means. */
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
|
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
|
||||||
warning: ScheduleWarning | null
|
warning: ScheduleWarning | null
|
||||||
|
/** How long the stop is stood at, which for one left at a set time is what that time leaves. */
|
||||||
dwellMinutes: number | null
|
dwellMinutes: number | null
|
||||||
offRoadMeters: number | null
|
offRoadMeters: number | null
|
||||||
}
|
}
|
||||||
@@ -47,14 +49,15 @@ export interface StopRow {
|
|||||||
*
|
*
|
||||||
* The desktop stacks all of them, which is honest on a 420px rail and unreadable on a
|
* The desktop stacks all of them, which is honest on a 420px rail and unreadable on a
|
||||||
* phone. The order is by what changes the next decision soonest: being late moves every
|
* phone. The order is by what changes the next decision soonest: being late moves every
|
||||||
* arrival after it, running dry strands the car, and an over-long leg is a planning
|
* arrival after it, and so does missing the time a stop was meant to be left at; running
|
||||||
* remark you can act on tonight.
|
* dry strands the car; and an over-long leg is a planning remark you can act on tonight.
|
||||||
*/
|
*/
|
||||||
const WARNING_RANK: Record<ScheduleWarning['code'], number> = {
|
const WARNING_RANK: Record<ScheduleWarning['code'], number> = {
|
||||||
late: 0,
|
late: 0,
|
||||||
range: 1,
|
missedLeave: 1,
|
||||||
leg: 2,
|
range: 2,
|
||||||
overnight: 3,
|
leg: 3,
|
||||||
|
overnight: 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
|
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
|
||||||
@@ -92,7 +95,7 @@ function stopRow(
|
|||||||
departure: entry?.departure ?? null,
|
departure: entry?.departure ?? null,
|
||||||
pinned: !!entry?.anchored,
|
pinned: !!entry?.anchored,
|
||||||
warning: pickWarning(mine),
|
warning: pickWarning(mine),
|
||||||
dwellMinutes: stop.dwellMinutes,
|
dwellMinutes: readStay(stop, entry).minutes,
|
||||||
offRoadMeters: stop.offRoadMeters ?? null,
|
offRoadMeters: stop.offRoadMeters ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
|
||||||
|
import { inspectorStay, locateStop, missedLeaveOf, readStay, shownStay, stayDraftOf } from './stayReading'
|
||||||
|
|
||||||
|
// FE-ROADTRIP-STAY-001..014: what a stay badge reads for a stop, and where the dialog
|
||||||
|
// finds the visit it opens on.
|
||||||
|
|
||||||
|
const stop = (over: Partial<RoadtripStop> = {}): RoadtripStop => ({
|
||||||
|
assignmentId: 7,
|
||||||
|
ownerDayId: 1,
|
||||||
|
ownerIndex: 1,
|
||||||
|
placeId: 70,
|
||||||
|
name: 'Lueneburg',
|
||||||
|
lat: 53.2,
|
||||||
|
lng: 10.4,
|
||||||
|
time: null,
|
||||||
|
dwellMinutes: 30,
|
||||||
|
legMode: null,
|
||||||
|
incomingLegMode: null,
|
||||||
|
stopType: null,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const entry = (arrival: string | null, departure: string | null) => ({ arrival, departure, anchored: false, dayOffset: 0 })
|
||||||
|
|
||||||
|
const day = (dayId: number, stops: RoadtripStop[], entries = stops.map(() => entry('10:00', '14:00'))): RoadtripDay =>
|
||||||
|
({ dayId, dayNumber: dayId, date: null, title: null, stops, schedule: { entries, warnings: [] } }) as unknown as RoadtripDay
|
||||||
|
|
||||||
|
describe('readStay', () => {
|
||||||
|
it('FE-ROADTRIP-STAY-001: an ordinary stop reads the stay the place carries', () => {
|
||||||
|
expect(readStay(stop(), entry('10:00', '10:30'))).toEqual({ minutes: 30, until: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-002: a stop left at a set time reads the stay the schedule made of it', () => {
|
||||||
|
expect(readStay(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({ minutes: 240, until: '14:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-003: a stay across midnight is not read as a negative one', () => {
|
||||||
|
expect(readStay(stop({ leaveAt: '01:00' }), entry('23:30', '01:00')).minutes).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-004: without an arrival it can only say until when', () => {
|
||||||
|
expect(readStay(stop({ leaveAt: '14:00:00' }), entry(null, '14:00'))).toEqual({ minutes: null, until: '14:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-005: a leave time that is not a clock is no leave time', () => {
|
||||||
|
expect(readStay(stop({ leaveAt: 'later' }), undefined)).toEqual({ minutes: 30, until: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-012: reached after the time, it is left on arrival and stays nothing', () => {
|
||||||
|
expect(readStay(stop({ leaveAt: '14:00' }), entry('14:30', '14:30'))).toEqual({ minutes: 0, until: '14:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-013: travel hours that end first give no length, only until when', () => {
|
||||||
|
// Reached at half past ten, the window shuts at six, the End is at eight: the row's
|
||||||
|
// 18:00 is where the day stops, not a departure, so seven and a half hours would be
|
||||||
|
// a stay that ends neither at the End nor when the drive goes on the next morning.
|
||||||
|
expect(readStay(stop({ leaveAt: '20:00' }), entry('10:30', '18:00'))).toEqual({
|
||||||
|
minutes: null,
|
||||||
|
until: '20:00',
|
||||||
|
dayEndsFirst: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shownStay', () => {
|
||||||
|
it('FE-ROADTRIP-STAY-006: no stay on an ordinary stop shows nothing, a zero until a time shows zero', () => {
|
||||||
|
expect(shownStay({ minutes: 0, until: null })).toBeNull()
|
||||||
|
expect(shownStay({ minutes: null, until: null })).toBeNull()
|
||||||
|
expect(shownStay({ minutes: 45, until: null })).toBe(45)
|
||||||
|
expect(shownStay({ minutes: 0, until: '14:00' })).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('locateStop', () => {
|
||||||
|
it('FE-ROADTRIP-STAY-007: finds the visit on the card that draws it, starting at the one it names', () => {
|
||||||
|
const drawn = stop({ ownerDayId: 1 })
|
||||||
|
const days = [day(1, [stop({ assignmentId: 1 })]), day(2, [drawn])]
|
||||||
|
const located = locateStop(days, 7, 1)
|
||||||
|
expect(located).toMatchObject({ index: 0, stop: drawn })
|
||||||
|
expect(located?.day.dayId).toBe(2)
|
||||||
|
expect(locateStop(days, 99)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-008: an automatic night standing on the stop is not the stop', () => {
|
||||||
|
const night = stop({ automaticNight: { phase: 'end', fromDayNumber: 1 } })
|
||||||
|
expect(locateStop([day(1, [night])], 7)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the dialog draft', () => {
|
||||||
|
it('FE-ROADTRIP-STAY-009: carries the visit, its end time and both of its clocks', () => {
|
||||||
|
expect(stayDraftOf(stop({ leaveAt: '14:00' }), entry('10:00', '14:00'))).toEqual({
|
||||||
|
placeId: 70,
|
||||||
|
name: 'Lueneburg',
|
||||||
|
minutes: 30,
|
||||||
|
arrival: '10:00',
|
||||||
|
departure: '14:00',
|
||||||
|
leaveAt: '14:00',
|
||||||
|
missedBy: null,
|
||||||
|
assignmentId: 7,
|
||||||
|
dayId: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-014: carries the finding of a missed time from the card, for the inspector too', () => {
|
||||||
|
const visit = stop({ leaveAt: '14:00' })
|
||||||
|
const late = {
|
||||||
|
...day(1, [stop({ assignmentId: 1 }), visit], [entry('13:30', '13:30'), entry('14:30', '14:30')]),
|
||||||
|
schedule: {
|
||||||
|
entries: [entry('13:30', '13:30'), entry('14:30', '14:30')],
|
||||||
|
warnings: [
|
||||||
|
{ index: 1, code: 'late' as const, minutes: 5 },
|
||||||
|
{ index: 1, code: 'missedLeave' as const, minutes: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as RoadtripDay
|
||||||
|
expect(missedLeaveOf(late, 1)).toBe(30)
|
||||||
|
expect(missedLeaveOf(late, 0)).toBeNull()
|
||||||
|
expect(stayDraftOf(visit, entry('14:30', '14:30'), 30).missedBy).toBe(30)
|
||||||
|
const onEdit = vi.fn()
|
||||||
|
inspectorStay([late], visit, { id: 70, name: 'Lueneburg' }, onEdit).onEdit?.()
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ missedBy: 30, departure: '14:30' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-010: the inspector tile reads the drive the rail reads', () => {
|
||||||
|
const onEdit = vi.fn()
|
||||||
|
const visit = stop({ leaveAt: '14:00' })
|
||||||
|
const tile = inspectorStay([day(1, [visit])], visit, { id: 70, name: 'Lueneburg', duration_minutes: 30 }, onEdit)
|
||||||
|
expect(tile).toMatchObject({ minutes: 240, until: '14:00' })
|
||||||
|
tile.onEdit?.()
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ leaveAt: '14:00', arrival: '10:00', assignmentId: 7 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-STAY-011: a place not on the drive falls back to the stay it carries', () => {
|
||||||
|
const onEdit = vi.fn()
|
||||||
|
const tile = inspectorStay([], undefined, { id: 70, name: 'Lueneburg', duration_minutes: 45 }, onEdit)
|
||||||
|
expect(tile).toMatchObject({ minutes: 45, until: null })
|
||||||
|
tile.onEdit?.()
|
||||||
|
expect(onEdit).toHaveBeenCalledWith({ placeId: 70, name: 'Lueneburg', minutes: 45, arrival: null })
|
||||||
|
expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).onEdit).toBeUndefined()
|
||||||
|
expect(inspectorStay([], stop({ dwellMinutes: 20 }), { id: 70, name: 'Lueneburg' }).minutes).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { formatClock, parseClock, type ScheduleEntry } from './roadtripModel'
|
||||||
|
import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
|
||||||
|
import type { StayDraft } from './RoadtripStayModal'
|
||||||
|
import type { RoadtripStayControl } from './VisitControls'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a stop is stood at, the way a stay badge shows it.
|
||||||
|
*
|
||||||
|
* For most stops that is the stay the place carries. A stop with a time set to leave it
|
||||||
|
* is stood at from whenever the drive gets there until that time, so its stay is what
|
||||||
|
* the schedule made of it and `until` says when it ends. The number is read here and
|
||||||
|
* never written back to the place: the stay belongs to the place, the end time to one
|
||||||
|
* visit of it, and a stored copy would be wrong the moment the arrival moved.
|
||||||
|
*/
|
||||||
|
export interface StayReading {
|
||||||
|
minutes: number | null
|
||||||
|
/** The time the stop is left at as it was set, or null for a stop that takes its stay. */
|
||||||
|
until: string | null
|
||||||
|
/**
|
||||||
|
* The daily travel hours close before `until` comes. The stop is where that day ends and
|
||||||
|
* the drive goes on the next morning, so it is left neither at that time nor at the one
|
||||||
|
* the row shows, and no length measured against either would be true.
|
||||||
|
*/
|
||||||
|
dayEndsFirst?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStay(
|
||||||
|
stop: Pick<RoadtripStop, 'dwellMinutes' | 'leaveAt'>,
|
||||||
|
entry: { arrival?: string | null; departure?: string | null } | null | undefined,
|
||||||
|
): StayReading {
|
||||||
|
const leave = parseClock(stop.leaveAt)
|
||||||
|
if (leave === null) return { minutes: stop.dwellMinutes, until: null }
|
||||||
|
const until = formatClock(leave)
|
||||||
|
const arrival = parseClock(entry?.arrival)
|
||||||
|
const departure = parseClock(entry?.departure)
|
||||||
|
if (arrival === null || departure === null) return { minutes: null, until }
|
||||||
|
// A schedule leaves such a stop at the time, or on arrival when it got there too late
|
||||||
|
// for it. Any other departure is the window shutting first.
|
||||||
|
if (departure !== leave && departure !== arrival) return { minutes: null, until, dayEndsFirst: true }
|
||||||
|
return {
|
||||||
|
// Round the clock once: reached before midnight and left the morning after, the
|
||||||
|
// departure reads as the smaller of the two.
|
||||||
|
minutes: (departure - arrival + 1440) % 1440,
|
||||||
|
until,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How late the drive gets to a stop for the time it is set to be left at, or null. */
|
||||||
|
export function missedLeaveOf(day: Pick<RoadtripDay, 'schedule'>, index: number): number | null {
|
||||||
|
return day.schedule.warnings.find(w => w.index === index && w.code === 'missedLeave')?.minutes ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length a stay badge prints, or null for none.
|
||||||
|
*
|
||||||
|
* No stay on an ordinary stop is nothing to show. On a stop left at a set time a zero is
|
||||||
|
* an answer: it is left the moment the drive gets there.
|
||||||
|
*/
|
||||||
|
export function shownStay(stay: StayReading): number | null {
|
||||||
|
return stay.until ? stay.minutes : stay.minutes || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One visit on the routed days, with the entry of the card that draws it. */
|
||||||
|
export interface LocatedStop {
|
||||||
|
day: RoadtripDay
|
||||||
|
index: number
|
||||||
|
stop: RoadtripStop
|
||||||
|
entry: ScheduleEntry | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a visit across the routed days.
|
||||||
|
*
|
||||||
|
* A stop reached after a night drive is drawn on the card of the day it is reached on,
|
||||||
|
* not the one it is stored on, so `preferDayId` is only where the search starts.
|
||||||
|
*/
|
||||||
|
export function locateStop(
|
||||||
|
days: readonly RoadtripDay[],
|
||||||
|
assignmentId: number,
|
||||||
|
preferDayId?: number | null,
|
||||||
|
): LocatedStop | null {
|
||||||
|
const preferred = days.find(d => d.dayId === preferDayId)
|
||||||
|
for (const day of preferred ? [preferred, ...days.filter(d => d !== preferred)] : days) {
|
||||||
|
const index = day.stops.findIndex(s => s.assignmentId === assignmentId && !s.automaticNight)
|
||||||
|
if (index >= 0) return { day, index, stop: day.stops[index], entry: day.schedule.entries[index] }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the stay dialog opens on for one stop of a card.
|
||||||
|
*
|
||||||
|
* `missedBy` is the card's own finding, carried rather than worked out again from the two
|
||||||
|
* clocks: which day a leave time falls on is the schedule's call, and a second reading of
|
||||||
|
* it here is a second answer.
|
||||||
|
*/
|
||||||
|
export function stayDraftOf(stop: RoadtripStop, entry: ScheduleEntry | undefined, missedBy: number | null = null): StayDraft {
|
||||||
|
return {
|
||||||
|
placeId: stop.placeId,
|
||||||
|
name: stop.name,
|
||||||
|
minutes: stop.dwellMinutes,
|
||||||
|
arrival: entry?.arrival ?? null,
|
||||||
|
departure: entry?.departure ?? null,
|
||||||
|
leaveAt: stop.leaveAt ?? null,
|
||||||
|
missedBy,
|
||||||
|
assignmentId: stop.assignmentId,
|
||||||
|
dayId: stop.ownerDayId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The place inspector's Stay tile.
|
||||||
|
*
|
||||||
|
* With the visit on the drive it reads the same as the rail does. A place selected
|
||||||
|
* without one it can be pinned to (planned twice, say) falls back to the stay it carries.
|
||||||
|
*/
|
||||||
|
export function inspectorStay(
|
||||||
|
days: readonly RoadtripDay[],
|
||||||
|
stop: RoadtripStop | undefined,
|
||||||
|
place: { id: number; name: string; duration_minutes?: number | null },
|
||||||
|
onEdit?: (draft: StayDraft) => void,
|
||||||
|
): RoadtripStayControl {
|
||||||
|
const located = stop ? locateStop(days, stop.assignmentId, stop.ownerDayId) : null
|
||||||
|
const draft: StayDraft = located
|
||||||
|
? stayDraftOf(located.stop, located.entry, missedLeaveOf(located.day, located.index))
|
||||||
|
: { placeId: place.id, name: place.name, minutes: place.duration_minutes ?? null, arrival: null }
|
||||||
|
const reading = located
|
||||||
|
? readStay(located.stop, located.entry)
|
||||||
|
: { minutes: stop ? stop.dwellMinutes : place.duration_minutes ?? null, until: null }
|
||||||
|
return { ...reading, onEdit: onEdit ? () => onEdit(draft) : undefined }
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useLeaveMode, type LeaveSubject } from './useLeaveMode'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
|
||||||
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
|
||||||
|
// FE-ROADTRIP-LEAVE-001..014: the part of the stay dialog that is about a time set to
|
||||||
|
// leave a stop, and how it takes that time off the visit.
|
||||||
|
|
||||||
|
type AddToast = NonNullable<typeof window.__addToast>
|
||||||
|
|
||||||
|
const visit = {
|
||||||
|
id: 102,
|
||||||
|
day_id: 11,
|
||||||
|
place_id: 202,
|
||||||
|
order_index: 1,
|
||||||
|
assignment_time: '09:00',
|
||||||
|
assignment_end_time: '14:00',
|
||||||
|
place: { id: 202, name: 'Bremen', place_time: '09:00', end_time: '14:00' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject: LeaveSubject = { leaveAt: '14:00', arrival: '10:00', departure: '14:00', assignmentId: 102, dayId: 11 }
|
||||||
|
|
||||||
|
let setAssignmentTimes: ReturnType<typeof vi.fn>
|
||||||
|
let updatePlace: ReturnType<typeof vi.fn>
|
||||||
|
let refreshDays: ReturnType<typeof vi.fn>
|
||||||
|
let addToast: ReturnType<typeof vi.fn<AddToast>>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
setAssignmentTimes = vi.fn(async () => undefined)
|
||||||
|
updatePlace = vi.fn(async () => undefined)
|
||||||
|
refreshDays = vi.fn(async () => undefined)
|
||||||
|
addToast = vi.fn<AddToast>(() => 0)
|
||||||
|
window.__addToast = addToast
|
||||||
|
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, assignments: { '11': [visit] } })
|
||||||
|
useTripStore.setState({ setAssignmentTimes, updatePlace, refreshDays } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.__addToast
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useLeaveMode', () => {
|
||||||
|
it('FE-ROADTRIP-LEAVE-001: reads the time the stop is left at and the stay that makes', () => {
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
expect(result.current).toMatchObject({ until: '14:00', minutes: 240, removing: false })
|
||||||
|
expect(result.current.remove).toBeTypeOf('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-002: an ordinary stay is nothing for it to answer', () => {
|
||||||
|
const { result } = renderHook(() => useLeaveMode({ ...subject, leaveAt: null }))
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
expect(renderHook(() => useLeaveMode(null)).result.current.until).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-003: removing clears the End through the time write and keeps the Start', async () => {
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null })
|
||||||
|
// The day is read back as the place form reads it after the same write.
|
||||||
|
expect(refreshDays).toHaveBeenCalledWith(4)
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-004: a refused write says so and leaves the end time standing', async () => {
|
||||||
|
setAssignmentTimes.mockRejectedValueOnce(new Error('Denied'))
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(addToast).toHaveBeenCalledWith('Denied', 'error', undefined)
|
||||||
|
expect(result.current.until).toBe('14:00')
|
||||||
|
expect(refreshDays).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-005: nothing to remove for a traveller who may not change the day', () => {
|
||||||
|
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
|
||||||
|
const reader = renderHook(() => useLeaveMode(subject))
|
||||||
|
expect(reader.result.current.remove).toBeUndefined()
|
||||||
|
reader.unmount()
|
||||||
|
// Nor for a visit still waiting for the id the server gives it.
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 1 } })
|
||||||
|
expect(renderHook(() => useLeaveMode({ ...subject, assignmentId: -5 })).result.current.remove).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-006: an End only the place carries is taken off the place', async () => {
|
||||||
|
// The visit's own time write cannot reach it, so clearing that alone would change nothing.
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
places: [{ id: 202, name: 'Bremen', end_time: '14:00' }],
|
||||||
|
assignments: { '11': [{ ...visit, assignment_end_time: null }] },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
expect(result.current.until).toBe('14:00')
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(setAssignmentTimes).not.toHaveBeenCalled()
|
||||||
|
expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null })
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([['the same', '14:00'], ['another', '11:00']])(
|
||||||
|
'FE-ROADTRIP-LEAVE-009: with %s End on the place as well, removing clears both',
|
||||||
|
async (_label, placeEnd) => {
|
||||||
|
// A migration copied the place's End onto every visit, so older trips carry it
|
||||||
|
// twice. Clearing only the visit's left the stop leaving at the place's.
|
||||||
|
seedStore(useTripStore, { places: [{ id: 202, name: 'Bremen', end_time: placeEnd }] })
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '09:00', end_time: null })
|
||||||
|
expect(updatePlace).toHaveBeenCalledWith(4, 202, { end_time: null })
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-010: not offered when another visit of the place still leans on its End', () => {
|
||||||
|
// Taking the place's End would take that visit's too, and keeping it keeps this one's.
|
||||||
|
const other = { id: 150, day_id: 12, place_id: 202, order_index: 0, assignment_time: null, assignment_end_time: null }
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
places: [{ id: 202, name: 'Bremen', end_time: '14:00' }],
|
||||||
|
assignments: { '11': [visit], '12': [other] },
|
||||||
|
})
|
||||||
|
const leaning = renderHook(() => useLeaveMode(subject))
|
||||||
|
expect(leaning.result.current.until).toBe('14:00')
|
||||||
|
expect(leaning.result.current.remove).toBeUndefined()
|
||||||
|
leaning.unmount()
|
||||||
|
// One with an End of its own does not care what the place carries.
|
||||||
|
seedStore(useTripStore, { assignments: { '11': [visit], '12': [{ ...other, assignment_end_time: '16:00' }] } })
|
||||||
|
expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeTypeOf('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-011: not offered to a traveller who may not edit the place its End sits on', () => {
|
||||||
|
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } })
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 2 }, places: [{ id: 202, name: 'Bremen', end_time: '14:00' }] })
|
||||||
|
expect(renderHook(() => useLeaveMode(subject)).result.current.remove).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-012: through the real store the stop has no End left afterwards', async () => {
|
||||||
|
// Nothing stubbed but the day read back: the visit's time write and the place write
|
||||||
|
// both go out, and the End the road trip reads for the visit is gone.
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
|
||||||
|
const pool = buildPlace({ id: 202, trip_id: 4, name: 'Bremen', place_time: null, end_time: '14:00' })
|
||||||
|
const stored = buildAssignment({
|
||||||
|
id: 102, day_id: 11, place_id: 202, assignment_time: '09:00', assignment_end_time: '14:00',
|
||||||
|
place: { ...pool, place_time: '09:00', end_time: '14:00' },
|
||||||
|
})
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 1 }, places: [pool], assignments: { '11': [stored] } })
|
||||||
|
useTripStore.setState({ refreshDays } as never)
|
||||||
|
server.use(
|
||||||
|
http.put('/api/trips/4/assignments/102/time', () =>
|
||||||
|
HttpResponse.json({ assignment: { ...stored, assignment_end_time: null, place: { ...pool, place_time: '09:00' } } })),
|
||||||
|
http.put('/api/trips/4/places/202', () => HttpResponse.json({ place: { ...pool, end_time: null } })),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
const after = useTripStore.getState().assignments['11']![0]!
|
||||||
|
expect(after.assignment_end_time ?? after.place?.end_time ?? null).toBeNull()
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
expect(addToast).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-013: a refused write shows what the server said, not the request library', async () => {
|
||||||
|
setAssignmentTimes.mockRejectedValueOnce({
|
||||||
|
message: 'Request failed with status code 404',
|
||||||
|
response: { status: 404, data: { error: 'Assignment not found' } },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(addToast).toHaveBeenCalledWith('Assignment not found', 'error', undefined)
|
||||||
|
// With nothing from the server, the translated fallback rather than the English boilerplate.
|
||||||
|
setAssignmentTimes.mockRejectedValueOnce({ message: 'Request failed with status code 500', response: { status: 500 } })
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(addToast).toHaveBeenLastCalledWith('common.unknownError', 'error', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-014: hands on the finding of a missed time and a day that ends first', () => {
|
||||||
|
expect(renderHook(() => useLeaveMode({ ...subject, arrival: '14:30', departure: '14:30', missedBy: 30 })).result.current)
|
||||||
|
.toMatchObject({ until: '14:00', minutes: 0, missedBy: 30, dayEndsFirst: false })
|
||||||
|
expect(renderHook(() => useLeaveMode({ ...subject, leaveAt: '20:00', arrival: '10:30', departure: '18:00' })).result.current)
|
||||||
|
.toMatchObject({ until: '20:00', minutes: null, missedBy: null, dayEndsFirst: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-008: a visit gone from the store by the time of the tap is left alone', async () => {
|
||||||
|
const { result } = renderHook(() => useLeaveMode(subject))
|
||||||
|
const remove = result.current.remove!
|
||||||
|
act(() => seedStore(useTripStore, { assignments: { '11': [] } }))
|
||||||
|
await act(async () => { await remove() })
|
||||||
|
expect(setAssignmentTimes).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-LEAVE-007: opened on another visit, the removal of the last one is forgotten', async () => {
|
||||||
|
const { result, rerender } = renderHook(({ s }) => useLeaveMode(s), { initialProps: { s: subject } })
|
||||||
|
await act(async () => { await result.current.remove!() })
|
||||||
|
expect(result.current.until).toBeNull()
|
||||||
|
rerender({ s: { ...subject, assignmentId: 103, leaveAt: '16:00' } })
|
||||||
|
await waitFor(() => expect(result.current.until).toBe('16:00'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from '../../i18n/TranslationContext'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { readStay } from './stayReading'
|
||||||
|
|
||||||
|
/** The visit a stay dialog is open on, as far as a time set to leave it goes. */
|
||||||
|
export interface LeaveSubject {
|
||||||
|
leaveAt?: string | null
|
||||||
|
arrival?: string | null
|
||||||
|
departure?: string | null
|
||||||
|
/** How many minutes after the time the drive gets here, as the schedule found it. */
|
||||||
|
missedBy?: number | null
|
||||||
|
assignmentId?: number
|
||||||
|
/** The day the visit is stored on, which is where the store files it. */
|
||||||
|
dayId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveMode {
|
||||||
|
/** The time the stop is left at, or null when the dialog is about an ordinary stay. */
|
||||||
|
until: string | null
|
||||||
|
/** How long that makes the stay, when the drive says when the stop is reached. */
|
||||||
|
minutes: number | null
|
||||||
|
/** The drive gets here this many minutes after the time, and leaves on arrival. */
|
||||||
|
missedBy: number | null
|
||||||
|
/** The travel hours close the day before the time comes, see `StayReading`. */
|
||||||
|
dayEndsFirst: boolean
|
||||||
|
/**
|
||||||
|
* Takes the end time off the visit. Absent for a traveller who may not make the writes
|
||||||
|
* that takes, and for an End that cannot go without taking another visit's with it: a
|
||||||
|
* button that changes nothing is worse than none.
|
||||||
|
*/
|
||||||
|
remove?: () => Promise<void>
|
||||||
|
removing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of the stay dialog that is about a time set to leave a stop.
|
||||||
|
*
|
||||||
|
* Shared by the desktop dialog and the phone sheet, so the two cannot answer differently.
|
||||||
|
* A stop the traveller leaves at a set time has no length to choose: it is stood at until
|
||||||
|
* then. So the dialog says when the drive leaves and what stay that makes, and offers to
|
||||||
|
* take the end time off the visit, after which the stay is a choice again.
|
||||||
|
*
|
||||||
|
* The End a visit is left at is its own, or else the one its place carries: the server
|
||||||
|
* reads COALESCE(assignment_end_time, places.end_time), and so do mergeAssignmentPlace and
|
||||||
|
* the road trip's stops. A migration copied the place's End onto every visit there was,
|
||||||
|
* so on older trips both hold it, and clearing only the visit's column left the stop
|
||||||
|
* leaving at the same time. Removing therefore clears both. The place's End is only taken
|
||||||
|
* when no other visit of the place still leans on it; when one does, the removal is not
|
||||||
|
* offered, because it could not take the End off this visit alone.
|
||||||
|
*
|
||||||
|
* The visit's own End is cleared through its time write, the one the place form saves
|
||||||
|
* Start and End with. That write sets both, so the visit's Start goes along as it stands.
|
||||||
|
*/
|
||||||
|
export function useLeaveMode(subject: LeaveSubject | null): LeaveMode {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const can = useCanDo()
|
||||||
|
const trip = useTripStore(s => s.trip)
|
||||||
|
const [removed, setRemoved] = useState(false)
|
||||||
|
const [removing, setRemoving] = useState(false)
|
||||||
|
const assignmentId = subject?.assignmentId
|
||||||
|
const dayId = subject?.dayId
|
||||||
|
const leaveAt = subject?.leaveAt ?? null
|
||||||
|
const ownEnd = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.assignment_end_time ?? null)
|
||||||
|
const placeId = useTripStore(s => s.assignments[String(dayId)]?.find(a => a.id === assignmentId)?.place_id ?? null)
|
||||||
|
const placeEnd = useTripStore(s => (placeId === null ? null : s.places.find(p => p.id === placeId)?.end_time ?? null))
|
||||||
|
const leanedOn = useTripStore(s => placeEnd !== null && Object.values(s.assignments).some(list =>
|
||||||
|
list.some(a => a.place_id === placeId && a.id !== assignmentId && a.assignment_end_time == null)))
|
||||||
|
|
||||||
|
// Opened on another visit, or on one whose end time changed: nothing was removed there.
|
||||||
|
useEffect(() => { setRemoved(false) }, [assignmentId, leaveAt])
|
||||||
|
|
||||||
|
const remove = useCallback(async () => {
|
||||||
|
const store = useTripStore.getState()
|
||||||
|
const visit = store.assignments[String(dayId)]?.find(a => a.id === assignmentId)
|
||||||
|
// Gone between the render that offered this and the tap.
|
||||||
|
if (!trip || !visit || dayId == null) return
|
||||||
|
const place = store.places.find(p => p.id === visit.place_id)
|
||||||
|
setRemoving(true)
|
||||||
|
try {
|
||||||
|
if (visit.assignment_end_time != null) {
|
||||||
|
await store.setAssignmentTimes(trip.id, dayId, visit.id, { place_time: visit.assignment_time ?? null, end_time: null })
|
||||||
|
}
|
||||||
|
if (place?.end_time != null) await store.updatePlace(trip.id, place.id, { end_time: null })
|
||||||
|
setRemoved(true)
|
||||||
|
// As the place form does after the same write: a Start can reorder the day on the
|
||||||
|
// server, and only the day read back shows it.
|
||||||
|
await store.refreshDays(trip.id)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.unknownError')))
|
||||||
|
} finally {
|
||||||
|
setRemoving(false)
|
||||||
|
}
|
||||||
|
}, [trip, dayId, assignmentId, toast, t])
|
||||||
|
|
||||||
|
const reading = readStay({ dwellMinutes: null, leaveAt }, subject)
|
||||||
|
const allowed = !!trip && assignmentId != null && assignmentId > 0 && (ownEnd !== null || placeEnd !== null)
|
||||||
|
&& (ownEnd === null || can('day_edit', trip))
|
||||||
|
&& (placeEnd === null || (can('place_edit', trip) && !leanedOn))
|
||||||
|
return {
|
||||||
|
until: removed ? null : reading.until,
|
||||||
|
minutes: reading.minutes,
|
||||||
|
missedBy: subject?.missedBy ?? null,
|
||||||
|
dayEndsFirst: reading.dayEndsFirst === true,
|
||||||
|
remove: allowed ? remove : undefined,
|
||||||
|
removing,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ interface StopSpec {
|
|||||||
incoming?: string | null
|
incoming?: string | null
|
||||||
stopType?: string | null
|
stopType?: string | null
|
||||||
noCoords?: boolean
|
noCoords?: boolean
|
||||||
|
/** The visit's own End. */
|
||||||
|
end?: string | null
|
||||||
|
/** An End only the place carries, for a visit that has none of its own. */
|
||||||
|
placeEnd?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
|
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
|
||||||
@@ -45,6 +49,7 @@ function assignment(spec: StopSpec, order: number): Assignment {
|
|||||||
place_id: spec.id * 10,
|
place_id: spec.id * 10,
|
||||||
order_index: order,
|
order_index: order,
|
||||||
assignment_time: spec.time ?? null,
|
assignment_time: spec.time ?? null,
|
||||||
|
assignment_end_time: spec.end ?? null,
|
||||||
leg_transport_mode: spec.legMode ?? null,
|
leg_transport_mode: spec.legMode ?? null,
|
||||||
incoming_leg_transport_mode: spec.incoming ?? null,
|
incoming_leg_transport_mode: spec.incoming ?? null,
|
||||||
place: {
|
place: {
|
||||||
@@ -53,6 +58,9 @@ function assignment(spec: StopSpec, order: number): Assignment {
|
|||||||
lat: spec.noCoords ? null : spec.at[0],
|
lat: spec.noCoords ? null : spec.at[0],
|
||||||
lng: spec.noCoords ? null : spec.at[1],
|
lng: spec.noCoords ? null : spec.at[1],
|
||||||
place_time: null,
|
place_time: null,
|
||||||
|
// Folded in the way the store keeps a visit (mergeAssignmentPlace): its own End,
|
||||||
|
// or else the place's.
|
||||||
|
end_time: spec.end ?? spec.placeEnd ?? null,
|
||||||
duration_minutes: spec.dwell ?? null,
|
duration_minutes: spec.dwell ?? null,
|
||||||
stop_type: spec.stopType ?? null,
|
stop_type: spec.stopType ?? null,
|
||||||
},
|
},
|
||||||
@@ -764,6 +772,80 @@ describe('useRoadtripRoutes', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('a visit End on the road trip', () => {
|
||||||
|
/** An hour from Hamburg to Lueneburg and another on to Berlin. */
|
||||||
|
const hourly = () => ({
|
||||||
|
coordinates: [HAMBURG, LUENEBURG, BERLIN],
|
||||||
|
distance: 200000,
|
||||||
|
duration: 7200,
|
||||||
|
legs: [{ distance: 100000, duration: 3600, text: '' }, { distance: 100000, duration: 3600, text: '' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
async function drive(stops: StopSpec[]) {
|
||||||
|
calculateRouteWithLegs.mockResolvedValue(hourly())
|
||||||
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops)))
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||||
|
await waitFor(() => expect(result.current.days).toHaveLength(1))
|
||||||
|
return result.current.days[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-030: the drive leaves a stop at its End, not after its stay', async () => {
|
||||||
|
const d = await drive([
|
||||||
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
||||||
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
||||||
|
{ id: 3, at: BERLIN },
|
||||||
|
])
|
||||||
|
expect(d.stops[1].leaveAt).toBe('14:00')
|
||||||
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
|
||||||
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
||||||
|
expect(d.schedule.warnings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-031: an End the place carries counts for a visit without its own', async () => {
|
||||||
|
const d = await drive([
|
||||||
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
||||||
|
{ id: 2, at: LUENEBURG, placeEnd: '13:00', dwell: 30 },
|
||||||
|
{ id: 3, at: BERLIN },
|
||||||
|
])
|
||||||
|
expect(d.schedule.entries[1].departure).toBe('13:00')
|
||||||
|
expect(d.schedule.entries[2].arrival).toBe('14:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-032: Start and End together make the stay the time between them', async () => {
|
||||||
|
const d = await drive([
|
||||||
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
||||||
|
{ id: 2, at: LUENEBURG, time: '10:00', end: '14:00', dwell: 60 },
|
||||||
|
{ id: 3, at: BERLIN },
|
||||||
|
])
|
||||||
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true })
|
||||||
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-033: a stop reached after its End says so instead of leaving quietly', async () => {
|
||||||
|
const d = await drive([
|
||||||
|
{ id: 1, at: HAMBURG, time: '13:30', dwell: 0 },
|
||||||
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
||||||
|
{ id: 3, at: BERLIN },
|
||||||
|
])
|
||||||
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '14:30', departure: '14:30' })
|
||||||
|
expect(d.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-034: daily travel times spend the hours until the End as well', async () => {
|
||||||
|
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '20:00' } }))
|
||||||
|
const d = await drive([
|
||||||
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
||||||
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
||||||
|
{ id: 3, at: BERLIN },
|
||||||
|
])
|
||||||
|
expect(d.automaticSchedule).toBe(true)
|
||||||
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
|
||||||
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||||
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommoda
|
|||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lng: p.lng,
|
lng: p.lng,
|
||||||
time: a.assignment_time ?? p.place_time ?? null,
|
time: a.assignment_time ?? p.place_time ?? null,
|
||||||
|
// The visit's End is when the drive leaves it. Unlike a check-out it is the
|
||||||
|
// traveller's own statement about this stop, and the stay then runs until it.
|
||||||
|
leaveAt: a.assignment_end_time ?? p.end_time ?? null,
|
||||||
checkInTime: stay?.check_in ?? null,
|
checkInTime: stay?.check_in ?? null,
|
||||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||||
endDay: a.end_day === true,
|
endDay: a.end_day === true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ArrowRight, Minus, Plus } from 'lucide-react'
|
import { AlertTriangle, ArrowRight, Minus, Plus } from 'lucide-react'
|
||||||
import MSheet from '../../../components/MSheet'
|
import MSheet from '../../../components/MSheet'
|
||||||
import MChip from '../../../components/MChip'
|
import MChip from '../../../components/MChip'
|
||||||
import type { MTripSheetsProps } from '../MTripShell'
|
import type { MTripSheetsProps } from '../MTripShell'
|
||||||
@@ -7,9 +7,11 @@ import { useTranslation } from '../../../../i18n'
|
|||||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||||
import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel'
|
import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel'
|
||||||
import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
|
import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||||
|
import { locateStop, missedLeaveOf } from '../../../../components/Roadtrip/stayReading'
|
||||||
|
import { useLeaveMode, type LeaveMode } from '../../../../components/Roadtrip/useLeaveMode'
|
||||||
import { formatClockTime } from '../../../../utils/formatters'
|
import { formatClockTime } from '../../../../utils/formatters'
|
||||||
import { FormSheetHeader } from '../sheets/PlSheetChrome'
|
import { FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||||
import { INNER_CLS } from '../sheets/MTripSheetUi'
|
import { INNER_CLS, displayTime } from '../sheets/MTripSheetUi'
|
||||||
|
|
||||||
/** What the chain hands over when a stop's stay is tapped. */
|
/** What the chain hands over when a stop's stay is tapped. */
|
||||||
interface RtStaySheetPayload {
|
interface RtStaySheetPayload {
|
||||||
@@ -102,12 +104,24 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
*/
|
*/
|
||||||
const stage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId)
|
const stage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId)
|
||||||
const stopPlaceId = stop?.placeId
|
const stopPlaceId = stop?.placeId
|
||||||
|
// The visit itself when the stop sheet named it, read off the same stage: its end time
|
||||||
|
// decides whether there is a stay to choose here at all.
|
||||||
|
const located = stage && stop?.assignmentId != null ? locateStop([stage], stop.assignmentId) : null
|
||||||
const arrival = useMemo(() => {
|
const arrival = useMemo(() => {
|
||||||
|
if (located) return located.entry?.arrival ?? null
|
||||||
if (!stage || stopPlaceId == null) return null
|
if (!stage || stopPlaceId == null) return null
|
||||||
const index = stage.stops.findIndex(s => s.placeId === stopPlaceId)
|
const index = stage.stops.findIndex(s => s.placeId === stopPlaceId)
|
||||||
if (index === -1) return null
|
if (index === -1) return null
|
||||||
return stage.schedule.entries[index]?.arrival ?? null
|
return stage.schedule.entries[index]?.arrival ?? null
|
||||||
}, [stage, stopPlaceId])
|
}, [located, stage, stopPlaceId])
|
||||||
|
const leave = useLeaveMode(located && {
|
||||||
|
leaveAt: located.stop.leaveAt,
|
||||||
|
arrival: located.entry?.arrival,
|
||||||
|
departure: located.entry?.departure,
|
||||||
|
missedBy: missedLeaveOf(located.day, located.index),
|
||||||
|
assignmentId: located.stop.assignmentId,
|
||||||
|
dayId: located.stop.ownerDayId,
|
||||||
|
})
|
||||||
|
|
||||||
// What the stay does to this stop: the arrival is fixed by the drive, the departure is
|
// What the stay does to this stop: the arrival is fixed by the drive, the departure is
|
||||||
// the one end this sheet moves.
|
// the one end this sheet moves.
|
||||||
@@ -170,9 +184,13 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
onClose={shell.closeSheet}
|
onClose={shell.closeSheet}
|
||||||
variant="bottom"
|
variant="bottom"
|
||||||
material="opaque"
|
material="opaque"
|
||||||
ariaLabel={t('roadtrip.stay.add')}
|
// Named for what it holds: a stop left at a set time has no stay to add.
|
||||||
|
ariaLabel={leave.until ? t('roadtrip.stop.stay') : t('roadtrip.stay.add')}
|
||||||
>
|
>
|
||||||
{stop && (
|
{stop && leave.until && (
|
||||||
|
<RtLeaveTime name={stop.name} leave={leave} arrival={arrival} departure={located?.entry?.departure ?? null} onClose={shell.closeSheet} />
|
||||||
|
)}
|
||||||
|
{stop && !leave.until && (
|
||||||
<>
|
<>
|
||||||
<FormSheetHeader
|
<FormSheetHeader
|
||||||
title={t('roadtrip.stay.add')}
|
title={t('roadtrip.stay.add')}
|
||||||
@@ -293,3 +311,74 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
</MSheet>
|
</MSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sheet for a stop the traveller leaves at a set time, where there is no stay to pick.
|
||||||
|
*
|
||||||
|
* It says when the drive leaves and what stay that makes, and offers the one thing there
|
||||||
|
* is to do: take the end time off the visit, after which the stay is a choice again.
|
||||||
|
*/
|
||||||
|
function RtLeaveTime({ name, leave, arrival, departure, onClose }: {
|
||||||
|
name?: string
|
||||||
|
leave: LeaveMode
|
||||||
|
arrival: string | null
|
||||||
|
departure: string | null
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const until = displayTime(leave.until, locale, timeFormat)
|
||||||
|
const captionCls = 'font-geist text-[0.5625rem] font-bold uppercase tracking-[.09em] text-m-faint'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSheetHeader title={t('roadtrip.stop.stay')} onClose={onClose} closeLabel={t('common.close')} />
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-1">
|
||||||
|
{name && <div className="truncate text-[0.875rem] font-semibold text-m-ink">{name}</div>}
|
||||||
|
<div className="mt-[18px] text-center text-[2.5rem] font-extrabold leading-none tabular-nums text-m-ink">
|
||||||
|
{leave.minutes === null ? t('roadtrip.stay.until', { time: until }) : formatDurationShort(leave.minutes * 60)}
|
||||||
|
</div>
|
||||||
|
{/* What the drive actually does, as the desktop dialog says it: reached too late it
|
||||||
|
leaves on arrival, with the travel hours over first it goes on in the morning. */}
|
||||||
|
{leave.missedBy !== null ? (
|
||||||
|
<div className="mt-[10px] flex items-center justify-center gap-1.5 text-center text-[0.8125rem] font-medium leading-snug text-[color:var(--m-st-pending)]">
|
||||||
|
<AlertTriangle size={14} strokeWidth={2} className="flex-none" aria-hidden="true" />
|
||||||
|
{t('roadtrip.warn.missedLeave', { minutes: leave.missedBy })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-[10px] text-center text-[0.8125rem] leading-snug text-m-muted">
|
||||||
|
{t(leave.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', { time: until })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{arrival && !leave.dayEndsFirst && (
|
||||||
|
<div className={`mt-[18px] flex items-center justify-center gap-4 rounded-[16px] px-3 py-[11px] ${INNER_CLS}`}>
|
||||||
|
<span className="flex flex-col items-center gap-[2px]">
|
||||||
|
<span className={captionCls}>{t('roadtrip.stay.arrive')}</span>
|
||||||
|
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-muted">
|
||||||
|
{displayTime(arrival, locale, timeFormat)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={14} strokeWidth={2} className="mt-[11px] flex-none text-m-faint" aria-hidden="true" />
|
||||||
|
<span className="flex flex-col items-center gap-[2px]">
|
||||||
|
<span className={captionCls}>{t('roadtrip.stay.leave')}</span>
|
||||||
|
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-ink">
|
||||||
|
{displayTime(departure ?? leave.until, locale, timeFormat)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{leave.remove && (
|
||||||
|
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void leave.remove?.() }}
|
||||||
|
disabled={leave.removing}
|
||||||
|
className="ml-auto inline-flex h-11 items-center rounded-full bg-m-act px-[18px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('roadtrip.stay.clearLeave')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useTripStore } from '../../../../store/tripStore'
|
|||||||
import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo'
|
import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo'
|
||||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||||
import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||||
|
import { readStay, shownStay } from '../../../../components/Roadtrip/stayReading'
|
||||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||||
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
|
import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
|
||||||
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
|
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
|
||||||
@@ -39,10 +40,11 @@ import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip'
|
|||||||
*
|
*
|
||||||
* Editing the place is the one way out of that rule, and it is a hand-over rather than a
|
* Editing the place is the one way out of that rule, and it is a hand-over rather than a
|
||||||
* control of this sheet: the pencil opens the place editor the plan tab opens, with this
|
* control of this sheet: the pencil opens the place editor the plan tab opens, with this
|
||||||
* very visit in context, and this sheet closes behind it. The time somebody fixed for a
|
* very visit in context, and this sheet closes behind it. The times somebody fixed for a
|
||||||
* stop is that editor's start time (the chain anchors on the visit's own start time), so
|
* stop are that editor's Start and End (the chain arrives at the visit's own Start and
|
||||||
* a time control here would be a second writer for one number. A booked night can also
|
* leaves at its End), so a time control here would be a second writer for one number. A
|
||||||
* be held by its check-in, which belongs to the booking and not to the place.
|
* booked night can also be held by its check-in, which belongs to the booking and not to
|
||||||
|
* the place.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface RtStopSheetPayload {
|
interface RtStopSheetPayload {
|
||||||
@@ -203,16 +205,17 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
// Every finding filed at this stop, not just the one the chain had room for: being late
|
// Every finding filed at this stop, not just the one the chain had room for: being late
|
||||||
// and driving too far are separate answers and the sheet is where both fit.
|
// and driving too far are separate answers and the sheet is where both fit.
|
||||||
const warnings: ScheduleWarning[] = [
|
const warnings: ScheduleWarning[] = [
|
||||||
...day.schedule.warnings.filter(w => w.index === index && w.code === 'late'),
|
...day.schedule.warnings.filter(w => w.index === index && (w.code === 'late' || w.code === 'missedLeave')),
|
||||||
...day.driveWarnings.filter(w => w.index === index && (w.code === 'leg' || w.code === 'range')),
|
...day.driveWarnings.filter(w => w.index === index && (w.code === 'leg' || w.code === 'range')),
|
||||||
]
|
]
|
||||||
const warningText = (w: ScheduleWarning): string => {
|
const warningText = (w: ScheduleWarning): string => {
|
||||||
if (w.code === 'late') return t('roadtrip.warn.late', { minutes: w.minutes ?? 0 })
|
if (w.code === 'late') return t('roadtrip.warn.late', { minutes: w.minutes ?? 0 })
|
||||||
|
if (w.code === 'missedLeave') return t('roadtrip.warn.missedLeave', { minutes: w.minutes ?? 0 })
|
||||||
if (w.code === 'range') return t('roadtrip.limit.range', { distance: formatDistance(w.sinceKm ?? 0, unit) })
|
if (w.code === 'range') return t('roadtrip.limit.range', { distance: formatDistance(w.sinceKm ?? 0, unit) })
|
||||||
return t('roadtrip.limit.legOver', { time: formatDurationShort((w.overMinutes ?? 0) * 60) })
|
return t('roadtrip.limit.legOver', { time: formatDurationShort((w.overMinutes ?? 0) * 60) })
|
||||||
}
|
}
|
||||||
const warningIcon = (w: ScheduleWarning): ReactNode => {
|
const warningIcon = (w: ScheduleWarning): ReactNode => {
|
||||||
if (w.code === 'late') return <AlertTriangle size={15} strokeWidth={2} />
|
if (w.code === 'late' || w.code === 'missedLeave') return <AlertTriangle size={15} strokeWidth={2} />
|
||||||
if (w.code === 'range') return <Fuel size={15} strokeWidth={2} />
|
if (w.code === 'range') return <Fuel size={15} strokeWidth={2} />
|
||||||
return <Hourglass size={15} strokeWidth={2} />
|
return <Hourglass size={15} strokeWidth={2} />
|
||||||
}
|
}
|
||||||
@@ -226,7 +229,13 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
else if (navTargets.length > 1) setNavOpen(true)
|
else if (navTargets.length > 1) setNavOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stayMinutes = stop.dwellMinutes
|
// A stop left at a set time is stood at until then: the stay is what that time leaves
|
||||||
|
// of it, and the sheet says until when.
|
||||||
|
const stay = readStay(stop, entry)
|
||||||
|
const missed = warnings.some(w => w.code === 'missedLeave')
|
||||||
|
const shown = shownStay(stay)
|
||||||
|
const until = stay.until ? t('roadtrip.stay.until', { time: displayTime(stay.until, locale, timeFormat) }) : null
|
||||||
|
const stayText = [shown === null ? null : formatDurationShort(shown * 60), until].filter(Boolean).join(' ')
|
||||||
const canEditPlace = planner.can('place_edit', planner.trip)
|
const canEditPlace = planner.can('place_edit', planner.trip)
|
||||||
const editStay = () => {
|
const editStay = () => {
|
||||||
// The row this sheet is on rides along, because the stay sheet comes back here when it
|
// The row this sheet is on rides along, because the stay sheet comes back here when it
|
||||||
@@ -235,7 +244,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
// that is not on screen, which is what the day swipe reads to decide it is blocked.
|
// that is not on screen, which is what the day swipe reads to decide it is blocked.
|
||||||
shell.openSheet('rtstay', {
|
shell.openSheet('rtstay', {
|
||||||
placeId: stop.placeId,
|
placeId: stop.placeId,
|
||||||
minutes: stayMinutes,
|
minutes: stop.dwellMinutes,
|
||||||
name: stop.name,
|
name: stop.name,
|
||||||
dayId: located.day.dayId,
|
dayId: located.day.dayId,
|
||||||
assignmentId: stop.assignmentId,
|
assignmentId: stop.assignmentId,
|
||||||
@@ -353,17 +362,27 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
<div className="mt-2 font-geist text-[0.65625rem] leading-snug text-m-faint">
|
<div className="mt-2 font-geist text-[0.65625rem] leading-snug text-m-faint">
|
||||||
{entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
|
{entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Only where it is true. Reached too late the stop is left on arrival, which
|
||||||
|
the finding below says; with the travel hours over first the drive goes on
|
||||||
|
in the morning, not at the time or at the Leave shown above. */}
|
||||||
|
{stay.until && !missed && (
|
||||||
|
<div className="mt-1 font-geist text-[0.65625rem] leading-snug text-m-faint">
|
||||||
|
{t(stay.dayEndsFirst ? 'roadtrip.stay.dayEndsFirst' : 'roadtrip.stay.leavesAt', {
|
||||||
|
time: displayTime(stay.until, locale, timeFormat),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── What this stop costs, and what the drive to it went past ── */}
|
{/* ── What this stop costs, and what the drive to it went past ── */}
|
||||||
{(!!stayMinutes || !!row.offRoadMeters || warnings.length > 0) && (
|
{(!!stayText || !!row.offRoadMeters || warnings.length > 0) && (
|
||||||
<div className="mt-2.5 flex flex-col gap-2">
|
<div className="mt-2.5 flex flex-col gap-2">
|
||||||
{!!stayMinutes && (
|
{!!stayText && (
|
||||||
<Finding
|
<Finding
|
||||||
icon={<Hourglass size={15} strokeWidth={2} />}
|
icon={<Hourglass size={15} strokeWidth={2} />}
|
||||||
label={t('roadtrip.stop.stayShort')}
|
label={t('roadtrip.stop.stayShort')}
|
||||||
value={formatDurationShort(stayMinutes * 60)}
|
value={stayText}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!!row.offRoadMeters && (
|
{!!row.offRoadMeters && (
|
||||||
@@ -399,7 +418,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
<ActionTile
|
<ActionTile
|
||||||
icon={<Clock size={16} strokeWidth={2} />}
|
icon={<Clock size={16} strokeWidth={2} />}
|
||||||
label={t('roadtrip.stop.stayShort')}
|
label={t('roadtrip.stop.stayShort')}
|
||||||
value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')}
|
value={stayText || t('roadtrip.stay.none')}
|
||||||
onClick={canEditPlace ? editStay : undefined}
|
onClick={canEditPlace ? editStay : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export default function PlTimeFields({
|
|||||||
planner, startTime, endTime, onChange, assignmentId, dayAssignments, hasTimeError,
|
planner, startTime, endTime, onChange, assignmentId, dayAssignments, hasTimeError,
|
||||||
}: PlTimeFieldsProps) {
|
}: PlTimeFieldsProps) {
|
||||||
const { t } = planner
|
const { t } = planner
|
||||||
|
// Opened from the road trip tab, where the End of a visit is when the drive leaves it.
|
||||||
|
// The plan tab reads the day as Days does, and there End stays a plain label.
|
||||||
|
const endIsLeave = planner.activeTab === 'roadtrip'
|
||||||
|
|
||||||
const collisions = useMemo(() => {
|
const collisions = useMemo(() => {
|
||||||
if (!startTime || startTime.length < 5) return []
|
if (!startTime || startTime.length < 5) return []
|
||||||
@@ -58,6 +61,9 @@ export default function PlTimeFields({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Eyebrow className="mb-[5px] uppercase">{t('places.endTime')}</Eyebrow>
|
<Eyebrow className="mb-[5px] uppercase">{t('places.endTime')}</Eyebrow>
|
||||||
<CustomTimePicker value={endTime} onChange={v => onChange('end_time', v)} />
|
<CustomTimePicker value={endTime} onChange={v => onChange('end_time', v)} />
|
||||||
|
{endIsLeave && (
|
||||||
|
<div className="mt-[5px] font-geist text-[0.65625rem] leading-snug text-m-faint">{t('roadtrip.stop.endIsLeave')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasTimeError && (
|
{hasTimeError && (
|
||||||
|
|||||||
@@ -1058,7 +1058,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
|||||||
/>
|
/>
|
||||||
</LazyPanel>
|
</LazyPanel>
|
||||||
)}
|
)}
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} />
|
||||||
<TripFormModal
|
<TripFormModal
|
||||||
isOpen={showTripForm}
|
isOpen={showTripForm}
|
||||||
onClose={() => setShowTripForm(false)}
|
onClose={() => setShowTripForm(false)}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ async function renderRoadtrip() {
|
|||||||
rt.routes.days = [{ ...rt.corridor.day, stops }]
|
rt.routes.days = [{ ...rt.corridor.day, stops }]
|
||||||
}
|
}
|
||||||
rt.routes.days = rt.routes.days.map(day => ({
|
rt.routes.days = rt.routes.days.map(day => ({
|
||||||
schedule: { entries: [] },
|
schedule: { entries: [], warnings: [] },
|
||||||
legs: [],
|
legs: [],
|
||||||
...day,
|
...day,
|
||||||
stops: (day.stops as Array<Record<string, unknown>>).map((stop, i) => ({
|
stops: (day.stops as Array<Record<string, unknown>>).map((stop, i) => ({
|
||||||
@@ -1972,7 +1972,7 @@ describe('useTripPlanner road trip: the phone feed', () => {
|
|||||||
rt.routes.days = [{
|
rt.routes.days = [{
|
||||||
dayId,
|
dayId,
|
||||||
dayNumber: 1,
|
dayNumber: 1,
|
||||||
schedule: { entries: [] },
|
schedule: { entries: [], warnings: [] },
|
||||||
legs: [],
|
legs: [],
|
||||||
stops: [0, 1, 2].map((i) => ({
|
stops: [0, 1, 2].map((i) => ({
|
||||||
assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i,
|
assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel'
|
||||||
import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop'
|
import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop'
|
||||||
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup'
|
||||||
|
import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
|
||||||
|
import { inspectorStay } from '../../components/Roadtrip/stayReading'
|
||||||
import type { RoadtripStopType } from '@trek/shared'
|
import type { RoadtripStopType } from '@trek/shared'
|
||||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||||
@@ -1229,7 +1231,7 @@ export function useTripPlanner() {
|
|||||||
* the rail is a list — putting a dialog's state inside a row means it dies whenever the
|
* the rail is a list — putting a dialog's state inside a row means it dies whenever the
|
||||||
* list re-renders around it.
|
* list re-renders around it.
|
||||||
*/
|
*/
|
||||||
const [stayDraft, setStayDraft] = useState<{ placeId: number; name: string; minutes: number | null; arrival: string | null } | null>(null)
|
const [stayDraft, setStayDraft] = useState<StayDraft | null>(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the day ends at this stop, from BOTH the things that can end it.
|
* Whether the day ends at this stop, from BOTH the things that can end it.
|
||||||
@@ -2437,7 +2439,7 @@ export function useTripPlanner() {
|
|||||||
? { active: roadtripEndsDayAt(endDayStop), onToggle: () => setRoadtripEndDay(endDayStop) }
|
? { active: roadtripEndsDayAt(endDayStop), onToggle: () => setRoadtripEndDay(endDayStop) }
|
||||||
: undefined
|
: undefined
|
||||||
const roadtripStay = roadtripActive && selectedPlace
|
const roadtripStay = roadtripActive && selectedPlace
|
||||||
? { 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 }
|
? inspectorStay(roadtripRoutes.days, endDayStop, selectedPlace, can('place_edit', trip) ? editRoadtripStay : undefined)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Build placeId → order-number map from the selected day's assignments
|
// Build placeId → order-number map from the selected day's assignments
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { offlineDb, clearAll } from '../db/offlineDb'
|
|||||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||||
import { mutationQueue } from '../sync/mutationQueue'
|
import { mutationQueue } from '../sync/mutationQueue'
|
||||||
import { apiClient } from '../api/client'
|
import { apiClient, assignmentsApi } from '../api/client'
|
||||||
import type { Assignment, Day } from '../types'
|
import type { Assignment, Day } from '../types'
|
||||||
|
|
||||||
vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() }))
|
vi.mock('../api/assignmentEndDay', () => ({ saveAssignmentEndDay: vi.fn() }))
|
||||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => true) }))
|
||||||
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
vi.mock('../sync/authGate', () => ({ isAuthed: () => true }))
|
||||||
vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() } }))
|
vi.mock('../api/client', () => ({ apiClient: { request: vi.fn() }, assignmentsApi: { updateTime: vi.fn() } }))
|
||||||
const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment
|
const assignment = { id: 7, day_id: 1, place_id: 2, order_index: 0, assignment_time: '07:00', place: { id: 2, name: 'Berlin' } } as Assignment
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -50,3 +50,32 @@ describe('assignment day-end persistence', () => {
|
|||||||
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined()
|
expect((await offlineDb.days.get(1))?.assignments?.[0].end_day).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('assignment time persistence', () => {
|
||||||
|
const times = { place_time: '07:00', end_time: null }
|
||||||
|
|
||||||
|
it('stores the offline times with a replayable write of both of them', async () => {
|
||||||
|
await assignmentRepo.setTimes(9, { ...assignment, assignment_end_time: '14:00' }, times)
|
||||||
|
expect((await offlineDb.days.get(1))?.assignments?.[0]).toMatchObject({ assignment_time: '07:00', assignment_end_time: null })
|
||||||
|
expect(await offlineDb.mutationQueue.toArray()).toEqual([expect.objectContaining({
|
||||||
|
url: '/trips/9/assignments/7/time', method: 'PUT', body: times, resource: 'assignments', entityId: 7,
|
||||||
|
})])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves through the time route online and caches what came back', async () => {
|
||||||
|
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||||
|
vi.mocked(assignmentsApi.updateTime).mockResolvedValue({ assignment: { ...assignment, assignment_end_time: null } })
|
||||||
|
const saved = await assignmentRepo.setTimes(9, { ...assignment, assignment_end_time: '14:00' }, times)
|
||||||
|
expect(assignmentsApi.updateTime).toHaveBeenCalledWith(9, 7, times)
|
||||||
|
expect(saved.assignment_end_time).toBeNull()
|
||||||
|
expect((await offlineDb.days.get(1))?.assignments?.[0].assignment_end_time).toBeNull()
|
||||||
|
expect(await offlineDb.mutationQueue.count()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the cached visit unchanged after a refused save', async () => {
|
||||||
|
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||||
|
vi.mocked(assignmentsApi.updateTime).mockRejectedValue(new Error('Denied'))
|
||||||
|
await expect(assignmentRepo.setTimes(9, assignment, times)).rejects.toThrow('Denied')
|
||||||
|
expect((await offlineDb.days.get(1))?.assignments?.[0]).toEqual(assignment)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import { assignmentSchema } from '@trek/shared'
|
||||||
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
import { saveAssignmentEndDay } from '../api/assignmentEndDay'
|
||||||
|
import { assignmentsApi } from '../api/client'
|
||||||
import { offlineDb } from '../db/offlineDb'
|
import { offlineDb } from '../db/offlineDb'
|
||||||
import { cacheAssignment } from '../db/cacheAssignment'
|
import { cacheAssignment } from '../db/cacheAssignment'
|
||||||
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
import { generateUUID, mutationQueue } from '../sync/mutationQueue'
|
||||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||||
import type { Assignment } from '../types'
|
import type { Assignment } from '../types'
|
||||||
|
|
||||||
|
/** Start and End of one visit, as the time route stores them: null is no time. */
|
||||||
|
export interface AssignmentTimes {
|
||||||
|
place_time: string | null
|
||||||
|
end_time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const assignmentRepo = {
|
export const assignmentRepo = {
|
||||||
async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise<Assignment> {
|
async setEndDay(tripId: number | string, assignment: Assignment, endDay: boolean): Promise<Assignment> {
|
||||||
if (!isEffectivelyOffline()) {
|
if (!isEffectivelyOffline()) {
|
||||||
@@ -23,4 +31,28 @@ export const assignmentRepo = {
|
|||||||
})
|
})
|
||||||
return updated
|
return updated
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A visit's own Start and End, the pair the time route takes.
|
||||||
|
*
|
||||||
|
* The route writes both columns every time, so a caller changing one of them hands the
|
||||||
|
* other in as it stands; leaving it out would clear it.
|
||||||
|
*/
|
||||||
|
async setTimes(tripId: number | string, assignment: Assignment, times: AssignmentTimes): Promise<Assignment> {
|
||||||
|
if (!isEffectivelyOffline()) {
|
||||||
|
const saved = assignmentSchema.parse((await assignmentsApi.updateTime(tripId, assignment.id, times)).assignment)
|
||||||
|
await cacheAssignment(saved)
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
const updated = { ...assignment, assignment_time: times.place_time, assignment_end_time: times.end_time }
|
||||||
|
await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => {
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: generateUUID(), tripId: Number(tripId), method: 'PUT',
|
||||||
|
url: `/trips/${tripId}/assignments/${assignment.id}/time`,
|
||||||
|
body: times, resource: 'assignments', entityId: assignment.id,
|
||||||
|
})
|
||||||
|
await cacheAssignment(updated)
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import type { StoreApi } from 'zustand'
|
|||||||
import type { TripStoreState } from '../tripStore'
|
import type { TripStoreState } from '../tripStore'
|
||||||
import type { Assignment, AssignmentsMap } from '../../types'
|
import type { Assignment, AssignmentsMap } from '../../types'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import { assignmentRepo } from '../../repo/assignmentRepo'
|
import { assignmentRepo, type AssignmentTimes } from '../../repo/assignmentRepo'
|
||||||
|
import { mergeAssignmentPlace } from './placesSlice'
|
||||||
|
|
||||||
type SetState = StoreApi<TripStoreState>['setState']
|
type SetState = StoreApi<TripStoreState>['setState']
|
||||||
type GetState = StoreApi<TripStoreState>['getState']
|
type GetState = StoreApi<TripStoreState>['getState']
|
||||||
|
|
||||||
export interface AssignmentsSlice {
|
export interface AssignmentsSlice {
|
||||||
setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise<void>
|
setAssignmentEndDay: (tripId: number | string, dayId: number, assignmentId: number, endDay: boolean) => Promise<void>
|
||||||
|
setAssignmentTimes: (tripId: number | string, dayId: number, assignmentId: number, times: AssignmentTimes) => Promise<void>
|
||||||
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
|
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
|
||||||
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
|
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
|
||||||
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
|
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
|
||||||
@@ -35,6 +37,29 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setAssignmentTimes: async (tripId, dayId, assignmentId, times) => {
|
||||||
|
const assignment = get().assignments[String(dayId)]?.find(a => a.id === assignmentId)
|
||||||
|
if (!assignment || assignmentId < 0) return
|
||||||
|
const replace = (next: Assignment) => set(state => ({
|
||||||
|
assignments: Object.fromEntries(Object.entries(state.assignments).map(([key, items]) => [
|
||||||
|
key, items.map(a => a.id === assignmentId ? next : a),
|
||||||
|
])),
|
||||||
|
}))
|
||||||
|
// The embedded place carries the times the visit shows, its own or else the place's,
|
||||||
|
// so it is merged again from the pool rather than patched field by field. The merged
|
||||||
|
// row is also what an offline save caches, or the old end time would come back from
|
||||||
|
// the cache on the next read.
|
||||||
|
const pool = get().places.find(p => p.id === assignment.place_id)
|
||||||
|
const merge = (next: Assignment) => (pool ? mergeAssignmentPlace(next, pool) : next)
|
||||||
|
const draft = merge({ ...assignment, assignment_time: times.place_time, assignment_end_time: times.end_time })
|
||||||
|
replace(draft)
|
||||||
|
try {
|
||||||
|
replace(merge({ ...draft, ...await assignmentRepo.setTimes(tripId, draft, times) }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
replace(assignment)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||||
const state = get()
|
const state = get()
|
||||||
const place = state.places.find(p => p.id === Number.parseInt(String(placeId)))
|
const place = state.places.find(p => p.id === Number.parseInt(String(placeId)))
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet'
|
import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet'
|
||||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||||
|
import { useAuthStore } from '../../../../src/store/authStore'
|
||||||
|
import { usePermissionsStore } from '../../../../src/store/permissionsStore'
|
||||||
|
import { useTripStore } from '../../../../src/store/tripStore'
|
||||||
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
||||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||||
|
|
||||||
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-018
|
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-025
|
||||||
//
|
//
|
||||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||||
// in English.
|
// in English.
|
||||||
@@ -229,4 +232,93 @@ describe('MRtStaySheet', () => {
|
|||||||
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('a stop left at a set time', () => {
|
||||||
|
// Bremen's End is 23:45, so the stay is whatever the arrival at 22:30 leaves of it,
|
||||||
|
// and there is nothing for the buttons to set.
|
||||||
|
const LEAVING = {
|
||||||
|
...STAGE,
|
||||||
|
stops: [STAGE.stops[0], { ...STAGE.stops[1], leaveAt: '23:45' }],
|
||||||
|
schedule: {
|
||||||
|
entries: [STAGE.schedule.entries[0], { arrival: '22:30', departure: '23:45', anchored: false, dayOffset: 0 }],
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
} as unknown as RoadtripDay
|
||||||
|
let setAssignmentTimes: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setAssignmentTimes = vi.fn(async () => undefined)
|
||||||
|
seedStore(useAuthStore, { user: { id: 1, role: 'user' } })
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: { id: 4, user_id: 1 },
|
||||||
|
assignments: { '11': [{ id: 102, day_id: 11, place_id: 202, order_index: 1, assignment_time: '22:00', assignment_end_time: '23:45' }] },
|
||||||
|
})
|
||||||
|
useTripStore.setState({ setAssignmentTimes, refreshDays: vi.fn(async () => undefined) } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-020: says when the drive leaves and what stay that makes, with nothing to set', () => {
|
||||||
|
renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
||||||
|
// Named for what it holds, as the desktop dialog is: there is no stay to add here.
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Time at this stop' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Time at this stop')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Add a stay')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('This visit has an end time, so the drive leaves at 23:45.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('22:30')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('23:45')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '5 minutes more' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-021: taking the end time off keeps the Start and writes through the visit', async () => {
|
||||||
|
const { planner } = renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove end time' }))
|
||||||
|
await waitFor(() => expect(setAssignmentTimes).toHaveBeenCalledWith(4, 11, 102, { place_time: '22:00', end_time: null }))
|
||||||
|
// The ordinary sheet is back, on the stay the place carries.
|
||||||
|
await waitFor(() => expect(value()).toHaveTextContent('30'))
|
||||||
|
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-022: a traveller who may not change the day is not offered the removal', () => {
|
||||||
|
usePermissionsStore.setState({ permissions: { day_edit: 'trip_owner' } })
|
||||||
|
seedStore(useTripStore, { trip: { id: 4, user_id: 2 } })
|
||||||
|
renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
||||||
|
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Remove end time' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-024: reached after the time, it says so instead of promising it', () => {
|
||||||
|
const late = {
|
||||||
|
...LEAVING,
|
||||||
|
schedule: {
|
||||||
|
entries: [STAGE.schedule.entries[0], { arrival: '23:55', departure: '23:55', anchored: false, dayOffset: 0 }],
|
||||||
|
warnings: [{ index: 1, code: 'missedLeave', minutes: 10 }],
|
||||||
|
},
|
||||||
|
} as unknown as RoadtripDay
|
||||||
|
renderSheet({ roadtripRoutes: { days: [late] } })
|
||||||
|
expect(screen.getByText('Arrives 10 min after the time you set to leave')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('This visit has an end time, so the drive leaves at 23:45.')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('23:55')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-025: travel hours over before the time say so, with no departure beside it', () => {
|
||||||
|
const closed = {
|
||||||
|
...LEAVING,
|
||||||
|
schedule: {
|
||||||
|
entries: [STAGE.schedule.entries[0], { arrival: '17:30', departure: '18:00', anchored: false, dayOffset: 0 }],
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
|
} as unknown as RoadtripDay
|
||||||
|
renderSheet({ roadtripRoutes: { days: [closed] } })
|
||||||
|
expect(screen.getByText('The travel day ends before 23:45, so the drive goes on the next morning.')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('18:00')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTAY-023: the close in the header writes nothing', () => {
|
||||||
|
const { shell } = renderSheet({ roadtripRoutes: { days: [LEAVING] } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||||
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(setAssignmentTimes).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTripStore } from '../../../../src/store/tripStore'
|
|||||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||||
|
|
||||||
// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-035
|
// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-038
|
||||||
//
|
//
|
||||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||||
// in English. `planner.t` from the fixture is never consulted here.
|
// in English. `planner.t` from the fixture is never consulted here.
|
||||||
@@ -511,4 +511,50 @@ describe('MRtStopSheet', () => {
|
|||||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||||
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
|
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('a stop left at a set time', () => {
|
||||||
|
// Lueneburg carries a stay of 30 min, but its End is two in the afternoon: the drive
|
||||||
|
// gets there at ten and keeps to the End.
|
||||||
|
const LUENEBURG = stop({ assignmentId: 105, placeId: 205, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' })
|
||||||
|
const leaving = (arrival: string, departure: string, warnings: unknown[] = []) => ({
|
||||||
|
...DAY_A,
|
||||||
|
stops: [HAMBURG, LUENEBURG],
|
||||||
|
schedule: {
|
||||||
|
entries: [
|
||||||
|
{ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 },
|
||||||
|
{ arrival, departure, anchored: false, dayOffset: 0 },
|
||||||
|
],
|
||||||
|
warnings,
|
||||||
|
},
|
||||||
|
driveWarnings: [],
|
||||||
|
}) as unknown as RoadtripDay
|
||||||
|
const open = (day: RoadtripDay) =>
|
||||||
|
renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 105 } } })
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTOP-036: reads the stay the End makes, and until when, on the finding and the tile', () => {
|
||||||
|
open(leaving('10:00', '14:00'))
|
||||||
|
expect(screen.getAllByText('4 h until 14:00')).toHaveLength(2)
|
||||||
|
expect(screen.queryByText('30 min')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTOP-037: under the two clocks it says the drive leaves at that time', () => {
|
||||||
|
open(leaving('10:00', '14:00'))
|
||||||
|
expect(screen.getByText('14:00')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('This visit has an end time, so the drive leaves at 14:00.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTOP-038: reached after it, the stop says by how much', () => {
|
||||||
|
open(leaving('14:30', '14:30', [{ index: 1, code: 'missedLeave', minutes: 30 }]))
|
||||||
|
expect(screen.getByText('Arrives 30 min after the time you set to leave')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('0 min until 14:00')).toHaveLength(2)
|
||||||
|
// Left at 14:30, so nothing under the clocks promises 14:00.
|
||||||
|
expect(screen.queryByText('This visit has an end time, so the drive leaves at 14:00.')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-RTSTOP-039: with the travel hours over first, it reads until when and says the drive goes on in the morning', () => {
|
||||||
|
open(leaving('10:00', '12:00'))
|
||||||
|
expect(screen.getAllByText('until 14:00')).toHaveLength(2)
|
||||||
|
expect(screen.getByText('The travel day ends before 14:00, so the drive goes on the next morning.')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { seedStore } from '../../../helpers/store'
|
|||||||
import { buildSettings } from '../../../helpers/factories'
|
import { buildSettings } from '../../../helpers/factories'
|
||||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||||
|
|
||||||
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-013
|
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-015
|
||||||
|
|
||||||
const planner = buildPlanner()
|
const planner = buildPlanner()
|
||||||
|
|
||||||
@@ -151,4 +151,24 @@ describe('PlTimeFields', () => {
|
|||||||
setup({ endTime: '11:0', dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00')] })
|
setup({ endTime: '11:0', dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00')] })
|
||||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-PLTIME-014: opened from the road trip tab, End says the drive leaves at it', () => {
|
||||||
|
render(
|
||||||
|
<PlTimeFields
|
||||||
|
planner={buildPlanner({ activeTab: 'roadtrip' } as never)}
|
||||||
|
startTime="10:00"
|
||||||
|
endTime="14:00"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
assignmentId={1}
|
||||||
|
dayAssignments={[SELF]}
|
||||||
|
hasTimeError={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('roadtrip.stop.endIsLeave')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MOB-PLTIME-015: from the plan tab End stays a plain label', () => {
|
||||||
|
setup()
|
||||||
|
expect(screen.queryByText('roadtrip.stop.endIsLeave')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -394,6 +394,55 @@ describe('assignmentsSlice', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setAssignmentTimes', () => {
|
||||||
|
// The pool place still carries an End of its own, which the visit shows once its
|
||||||
|
// own override is gone, exactly as a fresh read of the day would.
|
||||||
|
const pool = () => buildPlace({ id: 10, trip_id: 1, place_time: null, end_time: '18:00' });
|
||||||
|
const visit = () => buildAssignment({
|
||||||
|
id: 5, day_id: 1, place_id: 10, assignment_time: '09:00', assignment_end_time: '14:00',
|
||||||
|
place: { ...pool(), place_time: '09:00', end_time: '14:00' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ASSIGN-020: clears the End at once, with the place End showing through, and keeps the Start', async () => {
|
||||||
|
seedStore(useTripStore, { places: [pool()], assignments: { '1': [visit()] } });
|
||||||
|
let seen: unknown;
|
||||||
|
server.use(
|
||||||
|
http.put('/api/trips/1/assignments/5/time', async ({ request }) => {
|
||||||
|
seen = await request.json();
|
||||||
|
const mid = useTripStore.getState().assignments['1'][0];
|
||||||
|
expect(mid).toMatchObject({ assignment_end_time: null, place: { end_time: '18:00', place_time: '09:00' } });
|
||||||
|
return HttpResponse.json({ assignment: { ...visit(), assignment_end_time: null, place: { ...pool(), place_time: '09:00' } } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: '09:00', end_time: null });
|
||||||
|
|
||||||
|
expect(seen).toEqual({ place_time: '09:00', end_time: null });
|
||||||
|
expect(useTripStore.getState().assignments['1'][0]).toMatchObject({
|
||||||
|
assignment_time: '09:00', assignment_end_time: null, place: { end_time: '18:00' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ASSIGN-021: a refused write puts the visit back as it was', async () => {
|
||||||
|
seedStore(useTripStore, { places: [pool()], assignments: { '1': [visit()] } });
|
||||||
|
server.use(http.put('/api/trips/1/assignments/5/time', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
|
||||||
|
|
||||||
|
await expect(useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: '09:00', end_time: null })).rejects.toBeTruthy();
|
||||||
|
|
||||||
|
expect(useTripStore.getState().assignments['1'][0]).toMatchObject({ assignment_end_time: '14:00', place: { end_time: '14:00' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ASSIGN-022: a visit it cannot find, or one not yet saved, is left alone', async () => {
|
||||||
|
const unsaved = { ...visit(), id: -3 };
|
||||||
|
seedStore(useTripStore, { places: [pool()], assignments: { '1': [unsaved] } });
|
||||||
|
|
||||||
|
await useTripStore.getState().setAssignmentTimes(1, 1, 5, { place_time: null, end_time: null });
|
||||||
|
await useTripStore.getState().setAssignmentTimes(1, 1, -3, { place_time: null, end_time: null });
|
||||||
|
|
||||||
|
expect(useTripStore.getState().assignments['1'][0]).toEqual(unsaved);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('setAssignments', () => {
|
describe('setAssignments', () => {
|
||||||
it('FE-ASSIGN-019: replaces the whole assignments map', () => {
|
it('FE-ASSIGN-019: replaces the whole assignments map', () => {
|
||||||
seedStore(useTripStore, { assignments: { '1': [buildAssignment({ id: 1, day_id: 1 })] } });
|
seedStore(useTripStore, { assignments: { '1': [buildAssignment({ id: 1, day_id: 1 })] } });
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
splitIntoRuns,
|
splitIntoRuns,
|
||||||
spillChains,
|
spillChains,
|
||||||
dayWindow,
|
dayWindow,
|
||||||
parseClock,
|
|
||||||
effectiveRangeKm,
|
effectiveRangeKm,
|
||||||
parseAvoid,
|
parseAvoid,
|
||||||
formatDistance,
|
formatDistance,
|
||||||
@@ -34,6 +33,7 @@ interface StoredDay {
|
|||||||
}
|
}
|
||||||
interface VisitRow {
|
interface VisitRow {
|
||||||
check_in: string | null;
|
check_in: string | null;
|
||||||
|
/** The drive does not read these two (#2357): they are the booking as get_roadtrip_context reports it. */
|
||||||
check_out: string | null;
|
check_out: string | null;
|
||||||
checkout_day: number | null;
|
checkout_day: number | null;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,6 +43,7 @@ interface VisitRow {
|
|||||||
lat: number | null;
|
lat: number | null;
|
||||||
lng: number | null;
|
lng: number | null;
|
||||||
time: string | null;
|
time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
end_day: number;
|
end_day: number;
|
||||||
leg_transport_mode: string | null;
|
leg_transport_mode: string | null;
|
||||||
@@ -72,7 +73,8 @@ export class RoadtripPlanService {
|
|||||||
);
|
);
|
||||||
const visits = this.db.all<VisitRow>(
|
const visits = this.db.all<VisitRow>(
|
||||||
`SELECT a.id, a.day_id, a.place_id, p.name, p.lat, p.lng,
|
`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,
|
COALESCE(a.assignment_time, p.place_time) AS time, COALESCE(a.assignment_end_time, p.end_time) AS end_time,
|
||||||
|
p.duration_minutes, a.end_day,
|
||||||
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
|
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
|
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 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)
|
||||||
@@ -117,8 +119,10 @@ export class RoadtripPlanService {
|
|||||||
lat: v.lat!,
|
lat: v.lat!,
|
||||||
lng: v.lng!,
|
lng: v.lng!,
|
||||||
time: v.time,
|
time: v.time,
|
||||||
|
// The visit's end time is when the drive leaves it, the same reading the
|
||||||
|
// planner makes in the browser (useRoadtripRoutes).
|
||||||
|
leaveAt: v.end_time,
|
||||||
checkInTime: v.check_in,
|
checkInTime: v.check_in,
|
||||||
...(v.checkout_day != null && parseClock(v.check_out) !== null ? { checkoutAt: v.checkout_day * 1440 + parseClock(v.check_out)! } : {}),
|
|
||||||
dwellMinutes: v.duration_minutes,
|
dwellMinutes: v.duration_minutes,
|
||||||
endDay: v.end_day === 1,
|
endDay: v.end_day === 1,
|
||||||
legMode: v.leg_transport_mode,
|
legMode: v.leg_transport_mode,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class RoadtripPlanningMcp {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_roadtrip_context',
|
name: 'get_roadtrip_context',
|
||||||
description:
|
description:
|
||||||
'Read the saved roadtrip days, visits, pinned times, stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks and manual day endings. Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.',
|
||||||
inputSchema: { tripId: z.number().int().positive() },
|
inputSchema: { tripId: z.number().int().positive() },
|
||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||||
access: { group: 'trips', mode: 'read' },
|
access: { group: 'trips', mode: 'read' },
|
||||||
@@ -40,7 +40,7 @@ export class RoadtripPlanningMcp {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'calculate_roadtrip',
|
name: 'calculate_roadtrip',
|
||||||
description:
|
description:
|
||||||
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority; no accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
'Calculate a saved trip without an open browser, using the same daily scheduling and vehicle-range rules as the planner. Returns derived days, arrivals, departures, driving limits, fuel warnings, automatic pauses and routing failures. Fixed visit times retain priority. The end time of a visit is when the drive leaves it, in place of its stay; reaching the visit after it is reported as a missedLeave warning. No accommodation or place is created. Optional settings are a read-only preview, not saved. Distances are metres, durations seconds, dwell times minutes. A failed leg or conflicting window means the output is incomplete: never present its totals as a complete route. Up to 150 legs; requests are paced for public routing servers and can take time. Existing place/assignment tools edit stops, stays and ordering, update_roadtrip_settings saves preferences, set_assignment_end_day and set_day_boundary set exceptions. Recalculate after changes. Plugin profiles follow installed route-provider permissions.',
|
||||||
inputSchema: roadtripPlanRequestSchema.shape,
|
inputSchema: roadtripPlanRequestSchema.shape,
|
||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||||
access: { group: 'trips', mode: 'read' },
|
access: { group: 'trips', mode: 'read' },
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* RoadtripPlanService against the worker's real SQLite schema.
|
||||||
|
*
|
||||||
|
* The planning SELECT is the one place the server reads a visit's times for the road
|
||||||
|
* trip, and the tests beside the MCP tools hand the service its rows by hand, so a
|
||||||
|
* column the statement never selects would go unnoticed there. These run the statement.
|
||||||
|
*/
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
|
||||||
|
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||||
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/** Every leg an hour and 60 km, whatever it joins. */
|
||||||
|
function hourlyRouter() {
|
||||||
|
return {
|
||||||
|
profiles: () => ['driving'],
|
||||||
|
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({
|
||||||
|
parts: points.slice(1).map(() => ({ distance: 60000, duration: 3600 })),
|
||||||
|
avoidMissed: [],
|
||||||
|
leg: {
|
||||||
|
line: points.map((p) => [p.lat, p.lng]),
|
||||||
|
vias: [],
|
||||||
|
seg: {
|
||||||
|
from: [points[0].lat, points[0].lng],
|
||||||
|
to: [points[points.length - 1].lat, points[points.length - 1].lng],
|
||||||
|
mid: [points[0].lat, points[0].lng],
|
||||||
|
distance: 60000 * (points.length - 1),
|
||||||
|
duration: 3600 * (points.length - 1),
|
||||||
|
mode: 'driving',
|
||||||
|
distanceText: '',
|
||||||
|
drivingText: '',
|
||||||
|
walkingText: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const { user } = createUser(db);
|
||||||
|
const trip = createTrip(db, user.id);
|
||||||
|
const day = createDay(db, trip.id);
|
||||||
|
const visits = ['Hamburg', 'Lueneburg', 'Celle'].map((name, i) => {
|
||||||
|
const place = createPlace(db, trip.id, { name, lat: 53 - i * 0.3, lng: 10 });
|
||||||
|
db.prepare('UPDATE places SET duration_minutes = ? WHERE id = ?').run(i === 0 ? 0 : 30, place.id);
|
||||||
|
return createDayAssignment(db, day.id, place.id);
|
||||||
|
});
|
||||||
|
db.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(visits[0].id);
|
||||||
|
const plans = new RoadtripPlanService(
|
||||||
|
new DatabaseService(db),
|
||||||
|
{ getUserSettings: () => ({}) } as never,
|
||||||
|
{ read: () => ({}) } as never,
|
||||||
|
hourlyRouter() as never,
|
||||||
|
{ listForTrip: () => [], tracksForTrip: () => [] } as never,
|
||||||
|
{ list: () => [] } as never,
|
||||||
|
);
|
||||||
|
return { user, trip, visits, plans };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTestDb(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a visit end time on the road trip', () => {
|
||||||
|
it('is read from the visit, and from the place when the visit has none', () => {
|
||||||
|
const { user, trip, visits, plans } = setup();
|
||||||
|
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE places SET end_time = '18:00' WHERE id = (SELECT place_id FROM day_assignments WHERE id = ?)",
|
||||||
|
).run(visits[2].id);
|
||||||
|
|
||||||
|
const context = plans.context(trip.id, user.id);
|
||||||
|
|
||||||
|
expect(context.visits.map((v) => [v.name, v.end_time])).toEqual([
|
||||||
|
['Hamburg', null],
|
||||||
|
['Lueneburg', '14:00'],
|
||||||
|
['Celle', '18:00'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is when the drive leaves the stop, in place of its stay', async () => {
|
||||||
|
const { user, trip, visits, plans } = setup();
|
||||||
|
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
|
||||||
|
|
||||||
|
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||||
|
const day = calculated.days[0];
|
||||||
|
|
||||||
|
expect(day.stops[1]).toMatchObject({ name: 'Lueneburg', leaveAt: '14:00' });
|
||||||
|
expect(day.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
|
||||||
|
['09:00', '09:00'],
|
||||||
|
['10:00', '14:00'],
|
||||||
|
['15:00', '15:30'],
|
||||||
|
]);
|
||||||
|
expect(day.schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is reported when the drive gets there after it', async () => {
|
||||||
|
const { user, trip, visits, plans } = setup();
|
||||||
|
db.prepare("UPDATE day_assignments SET assignment_end_time = '09:30' WHERE id = ?").run(visits[1].id);
|
||||||
|
|
||||||
|
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||||
|
|
||||||
|
expect(calculated.days[0].schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
|
||||||
|
expect(calculated.days[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '10:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a stop without one to its stay', async () => {
|
||||||
|
const { user, trip, plans } = setup();
|
||||||
|
|
||||||
|
const { calculated } = await plans.calculate(trip.id, user.id);
|
||||||
|
|
||||||
|
expect(calculated.days[0].schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '10:30' });
|
||||||
|
expect(calculated.days[0].stops[1].leaveAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -176,6 +176,24 @@ describe('browser-independent roadtrip calculation', () => {
|
|||||||
const plan = await s.plans.calculate(10, 5);
|
const plan = await s.plans.calculate(10, 5);
|
||||||
expect(plan.omittedVisits).toEqual([2]);
|
expect(plan.omittedVisits).toEqual([2]);
|
||||||
});
|
});
|
||||||
|
it('hands calculate_roadtrip the end time of a visit as the moment the drive leaves it', async () => {
|
||||||
|
const s = setup();
|
||||||
|
// Stop 1 is left at 07:30 and the road takes two hours, so stop 2 is reached half an
|
||||||
|
// hour after the 09:00 it was meant to be left at. Stop 3 is left at noon, whatever
|
||||||
|
// its stay says.
|
||||||
|
Object.assign(s.visits[1], { end_time: '09:00' });
|
||||||
|
Object.assign(s.visits[2], { end_time: '12:00' });
|
||||||
|
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
|
||||||
|
const answer = await mcp.calculate(
|
||||||
|
{ tripId: 10, includeGeometry: false, settings: { roadtrip_day_start: '', roadtrip_day_end: '' } },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const body = JSON.parse(answer.content[0].text as string);
|
||||||
|
const [day] = body.days;
|
||||||
|
expect(day.stops.map((stop: { leaveAt: string | null }) => stop.leaveAt)).toEqual([undefined, '09:00', '12:00']);
|
||||||
|
expect(day.schedule.entries.map((e: { departure: string }) => e.departure)).toEqual(['07:30', '09:30', '12:00']);
|
||||||
|
expect(day.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
|
||||||
|
});
|
||||||
it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => {
|
it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => {
|
||||||
const s = setup();
|
const s = setup();
|
||||||
s.visits[0].end_day = 1;
|
s.visits[0].end_day = 1;
|
||||||
|
|||||||
@@ -239,8 +239,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'مدة التوقف هنا',
|
'roadtrip.stop.stay': 'مدة التوقف هنا',
|
||||||
'roadtrip.stop.pinned': 'الوقت الذي حددته',
|
'roadtrip.stop.pinned': 'الوقت الذي حددته',
|
||||||
'roadtrip.stop.computed': 'محسوب من زمن القيادة',
|
'roadtrip.stop.computed': 'محسوب من زمن القيادة',
|
||||||
|
'roadtrip.stop.endIsLeave': 'في الرحلة البرية، تنطلق الرحلة في هذا الوقت.',
|
||||||
'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة',
|
'roadtrip.via.hint': 'اسحب لإعادة تشكيل المسار، وانقر بالزر الأيمن للإزالة',
|
||||||
'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
|
'roadtrip.warn.late': 'الوصول بعد {minutes} دقيقة من الوقت المحدد',
|
||||||
|
'roadtrip.warn.missedLeave': 'الوصول بعد {minutes} دقيقة من وقت المغادرة المحدد',
|
||||||
'roadtrip.warn.overnight': 'اليوم التالي',
|
'roadtrip.warn.overnight': 'اليوم التالي',
|
||||||
'roadtrip.spill.title': 'من اليوم {number}',
|
'roadtrip.spill.title': 'من اليوم {number}',
|
||||||
'roadtrip.spill.departs': 'المغادرة {time}',
|
'roadtrip.spill.departs': 'المغادرة {time}',
|
||||||
@@ -250,6 +252,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'دقائق',
|
'roadtrip.stay.custom': 'دقائق',
|
||||||
'roadtrip.stay.arrive': 'الوصول',
|
'roadtrip.stay.arrive': 'الوصول',
|
||||||
'roadtrip.stay.leave': 'المغادرة',
|
'roadtrip.stay.leave': 'المغادرة',
|
||||||
|
'roadtrip.stay.until': 'حتى {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'لهذه الزيارة وقت انتهاء، لذلك تنطلق الرحلة في {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'ينتهي يوم السفر قبل {time}، لذلك تتابع الرحلة صباح اليوم التالي.',
|
||||||
|
'roadtrip.stay.clearLeave': 'إزالة وقت الانتهاء',
|
||||||
'roadtrip.alt.noMotorway': 'بدون طريق سريع',
|
'roadtrip.alt.noMotorway': 'بدون طريق سريع',
|
||||||
'roadtrip.alt.noToll': 'بدون رسوم',
|
'roadtrip.alt.noToll': 'بدون رسوم',
|
||||||
'roadtrip.alt.noFerry': 'بدون عبّارة',
|
'roadtrip.alt.noFerry': 'بدون عبّارة',
|
||||||
|
|||||||
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Tempo nesta parada',
|
'roadtrip.stop.stay': 'Tempo nesta parada',
|
||||||
'roadtrip.stop.pinned': 'Horário definido por você',
|
'roadtrip.stop.pinned': 'Horário definido por você',
|
||||||
'roadtrip.stop.computed': 'Calculado a partir do trajeto',
|
'roadtrip.stop.computed': 'Calculado a partir do trajeto',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Na viagem de carro, a partida é neste horário.',
|
||||||
'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover',
|
'roadtrip.via.hint': 'Arraste para remodelar a rota, clique com o botão direito para remover',
|
||||||
'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
|
'roadtrip.warn.late': 'Chega {minutes} min depois do horário definido',
|
||||||
|
'roadtrip.warn.missedLeave': 'Chega {minutes} min depois do horário de partida definido',
|
||||||
'roadtrip.warn.overnight': 'Dia seguinte',
|
'roadtrip.warn.overnight': 'Dia seguinte',
|
||||||
'roadtrip.spill.title': 'Do dia {number}',
|
'roadtrip.spill.title': 'Do dia {number}',
|
||||||
'roadtrip.spill.departs': 'saída {time}',
|
'roadtrip.spill.departs': 'saída {time}',
|
||||||
@@ -255,6 +257,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minutos',
|
'roadtrip.stay.custom': 'Minutos',
|
||||||
'roadtrip.stay.arrive': 'Chegada',
|
'roadtrip.stay.arrive': 'Chegada',
|
||||||
'roadtrip.stay.leave': 'Partida',
|
'roadtrip.stay.leave': 'Partida',
|
||||||
|
'roadtrip.stay.until': 'até {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Esta visita tem um horário de término, então a viagem segue às {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'O dia de viagem termina antes das {time}, então a viagem segue na manhã seguinte.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Remover horário de término',
|
||||||
'roadtrip.alt.noMotorway': 'Sem rodovia',
|
'roadtrip.alt.noMotorway': 'Sem rodovia',
|
||||||
'roadtrip.alt.noToll': 'Sem pedagio',
|
'roadtrip.alt.noToll': 'Sem pedagio',
|
||||||
'roadtrip.alt.noFerry': 'Sem balsa',
|
'roadtrip.alt.noFerry': 'Sem balsa',
|
||||||
|
|||||||
@@ -246,8 +246,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Temps en aquesta parada',
|
'roadtrip.stop.stay': 'Temps en aquesta parada',
|
||||||
'roadtrip.stop.pinned': 'Hora que has fixat',
|
'roadtrip.stop.pinned': 'Hora que has fixat',
|
||||||
'roadtrip.stop.computed': 'Calculat a partir del trajecte',
|
'roadtrip.stop.computed': 'Calculat a partir del trajecte',
|
||||||
|
'roadtrip.stop.endIsLeave': 'En el viatge per carretera, se surt a aquesta hora.',
|
||||||
'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar',
|
'roadtrip.via.hint': 'Arrossega per remodelar la ruta, clic dret per eliminar',
|
||||||
'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
|
'roadtrip.warn.late': 'Arriba {minutes} min després de l’hora fixada',
|
||||||
|
'roadtrip.warn.missedLeave': 'Arriba {minutes} min després de l’hora de sortida fixada',
|
||||||
'roadtrip.warn.overnight': 'Dia següent',
|
'roadtrip.warn.overnight': 'Dia següent',
|
||||||
'roadtrip.spill.title': 'Del dia {number}',
|
'roadtrip.spill.title': 'Del dia {number}',
|
||||||
'roadtrip.spill.departs': 'surt {time}',
|
'roadtrip.spill.departs': 'surt {time}',
|
||||||
@@ -257,6 +259,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuts',
|
'roadtrip.stay.custom': 'Minuts',
|
||||||
'roadtrip.stay.arrive': 'Arribada',
|
'roadtrip.stay.arrive': 'Arribada',
|
||||||
'roadtrip.stay.leave': 'Sortida',
|
'roadtrip.stay.leave': 'Sortida',
|
||||||
|
'roadtrip.stay.until': 'fins a les {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Aquesta visita té una hora de fi, així que el viatge surt a les {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'El dia de viatge acaba abans de les {time}, així que el viatge continua l’endemà al matí.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Treu l’hora de fi',
|
||||||
'roadtrip.alt.noMotorway': 'Sense autopista',
|
'roadtrip.alt.noMotorway': 'Sense autopista',
|
||||||
'roadtrip.alt.noToll': 'Sense peatges',
|
'roadtrip.alt.noToll': 'Sense peatges',
|
||||||
'roadtrip.alt.noFerry': 'Sense ferri',
|
'roadtrip.alt.noFerry': 'Sense ferri',
|
||||||
|
|||||||
@@ -242,8 +242,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Čas na této zastávce',
|
'roadtrip.stop.stay': 'Čas na této zastávce',
|
||||||
'roadtrip.stop.pinned': 'Čas, který jste nastavili',
|
'roadtrip.stop.pinned': 'Čas, který jste nastavili',
|
||||||
'roadtrip.stop.computed': 'Vypočteno z doby jízdy',
|
'roadtrip.stop.computed': 'Vypočteno z doby jízdy',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Na cestě autem se v tento čas odjíždí.',
|
||||||
'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete',
|
'roadtrip.via.hint': 'Tažením změníte trasu, pravým tlačítkem odeberete',
|
||||||
'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase',
|
'roadtrip.warn.late': 'Příjezd {minutes} min po nastaveném čase',
|
||||||
|
'roadtrip.warn.missedLeave': 'Příjezd {minutes} min po nastaveném čase odjezdu',
|
||||||
'roadtrip.warn.overnight': 'Následující den',
|
'roadtrip.warn.overnight': 'Následující den',
|
||||||
'roadtrip.spill.title': 'Ze dne {number}',
|
'roadtrip.spill.title': 'Ze dne {number}',
|
||||||
'roadtrip.spill.departs': 'odjezd {time}',
|
'roadtrip.spill.departs': 'odjezd {time}',
|
||||||
@@ -253,6 +255,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuty',
|
'roadtrip.stay.custom': 'Minuty',
|
||||||
'roadtrip.stay.arrive': 'Prijezd',
|
'roadtrip.stay.arrive': 'Prijezd',
|
||||||
'roadtrip.stay.leave': 'Odjezd',
|
'roadtrip.stay.leave': 'Odjezd',
|
||||||
|
'roadtrip.stay.until': 'do {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Tato návštěva má čas konce, proto cesta pokračuje v {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Cestovní den končí před {time}, proto cesta pokračuje až další ráno.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Odebrat čas konce',
|
||||||
'roadtrip.alt.noMotorway': 'Bez dalnice',
|
'roadtrip.alt.noMotorway': 'Bez dalnice',
|
||||||
'roadtrip.alt.noToll': 'Bez mytneho',
|
'roadtrip.alt.noToll': 'Bez mytneho',
|
||||||
'roadtrip.alt.noFerry': 'Bez trajektu',
|
'roadtrip.alt.noFerry': 'Bez trajektu',
|
||||||
|
|||||||
@@ -241,8 +241,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
|
'roadtrip.stop.stay': 'Aufenthalt an diesem Stopp',
|
||||||
'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
|
'roadtrip.stop.pinned': 'Von dir gesetzte Zeit',
|
||||||
'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
|
'roadtrip.stop.computed': 'Aus der Fahrzeit gerechnet',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Im Roadtrip geht die Fahrt zu dieser Zeit weiter.',
|
||||||
'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt',
|
'roadtrip.via.hint': 'Ziehen formt die Route um, Rechtsklick entfernt',
|
||||||
'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
|
'roadtrip.warn.late': 'Ankunft {minutes} min nach der gesetzten Zeit',
|
||||||
|
'roadtrip.warn.missedLeave': 'Ankunft {minutes} min nach der gesetzten Abfahrtszeit',
|
||||||
'roadtrip.warn.overnight': 'Folgetag',
|
'roadtrip.warn.overnight': 'Folgetag',
|
||||||
'roadtrip.spill.title': 'Von Tag {number}',
|
'roadtrip.spill.title': 'Von Tag {number}',
|
||||||
'roadtrip.spill.departs': 'ab {time}',
|
'roadtrip.spill.departs': 'ab {time}',
|
||||||
@@ -252,6 +254,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuten',
|
'roadtrip.stay.custom': 'Minuten',
|
||||||
'roadtrip.stay.arrive': 'Ankunft',
|
'roadtrip.stay.arrive': 'Ankunft',
|
||||||
'roadtrip.stay.leave': 'Abfahrt',
|
'roadtrip.stay.leave': 'Abfahrt',
|
||||||
|
'roadtrip.stay.until': 'bis {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Dieser Besuch hat eine Endzeit, deshalb geht die Fahrt um {time} weiter.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Der Reisetag endet vor {time}, deshalb geht die Fahrt am nächsten Morgen weiter.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Endzeit entfernen',
|
||||||
'roadtrip.alt.noMotorway': 'Ohne Autobahn',
|
'roadtrip.alt.noMotorway': 'Ohne Autobahn',
|
||||||
'roadtrip.alt.noToll': 'Ohne Maut',
|
'roadtrip.alt.noToll': 'Ohne Maut',
|
||||||
'roadtrip.alt.noFerry': 'Ohne Fähre',
|
'roadtrip.alt.noFerry': 'Ohne Fähre',
|
||||||
|
|||||||
@@ -238,8 +238,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Time at this stop',
|
'roadtrip.stop.stay': 'Time at this stop',
|
||||||
'roadtrip.stop.pinned': 'Time you set',
|
'roadtrip.stop.pinned': 'Time you set',
|
||||||
'roadtrip.stop.computed': 'Calculated from the drive',
|
'roadtrip.stop.computed': 'Calculated from the drive',
|
||||||
|
'roadtrip.stop.endIsLeave': 'On the road trip, the drive leaves at this time.',
|
||||||
'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove',
|
'roadtrip.via.hint': 'Drag to reshape the route, right-click to remove',
|
||||||
'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
|
'roadtrip.warn.late': 'Arrives {minutes} min after the time you set',
|
||||||
|
'roadtrip.warn.missedLeave': 'Arrives {minutes} min after the time you set to leave',
|
||||||
'roadtrip.warn.overnight': 'Next day',
|
'roadtrip.warn.overnight': 'Next day',
|
||||||
'roadtrip.spill.title': 'From day {number}',
|
'roadtrip.spill.title': 'From day {number}',
|
||||||
'roadtrip.spill.departs': 'leaves {time}',
|
'roadtrip.spill.departs': 'leaves {time}',
|
||||||
@@ -249,6 +251,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minutes',
|
'roadtrip.stay.custom': 'Minutes',
|
||||||
'roadtrip.stay.arrive': 'Arrive',
|
'roadtrip.stay.arrive': 'Arrive',
|
||||||
'roadtrip.stay.leave': 'Leave',
|
'roadtrip.stay.leave': 'Leave',
|
||||||
|
'roadtrip.stay.until': 'until {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'This visit has an end time, so the drive leaves at {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'The travel day ends before {time}, so the drive goes on the next morning.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Remove end time',
|
||||||
'roadtrip.alt.noMotorway': 'No motorway',
|
'roadtrip.alt.noMotorway': 'No motorway',
|
||||||
'roadtrip.alt.noToll': 'No tolls',
|
'roadtrip.alt.noToll': 'No tolls',
|
||||||
'roadtrip.alt.noFerry': 'No ferry',
|
'roadtrip.alt.noFerry': 'No ferry',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Tiempo en la parada',
|
'roadtrip.stop.stay': 'Tiempo en la parada',
|
||||||
'roadtrip.stop.pinned': 'Hora fijada por ti',
|
'roadtrip.stop.pinned': 'Hora fijada por ti',
|
||||||
'roadtrip.stop.computed': 'Calculado a partir del trayecto',
|
'roadtrip.stop.computed': 'Calculado a partir del trayecto',
|
||||||
|
'roadtrip.stop.endIsLeave': 'En el viaje por carretera, se sale a esta hora.',
|
||||||
'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar',
|
'roadtrip.via.hint': 'Arrastra para remodelar la ruta, clic derecho para quitar',
|
||||||
'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
|
'roadtrip.warn.late': 'Llega {minutes} min después de la hora fijada',
|
||||||
|
'roadtrip.warn.missedLeave': 'Llega {minutes} min después de la hora de salida fijada',
|
||||||
'roadtrip.warn.overnight': 'Día siguiente',
|
'roadtrip.warn.overnight': 'Día siguiente',
|
||||||
'roadtrip.spill.title': 'Del día {number}',
|
'roadtrip.spill.title': 'Del día {number}',
|
||||||
'roadtrip.spill.departs': 'sale {time}',
|
'roadtrip.spill.departs': 'sale {time}',
|
||||||
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minutos',
|
'roadtrip.stay.custom': 'Minutos',
|
||||||
'roadtrip.stay.arrive': 'Llegada',
|
'roadtrip.stay.arrive': 'Llegada',
|
||||||
'roadtrip.stay.leave': 'Salida',
|
'roadtrip.stay.leave': 'Salida',
|
||||||
|
'roadtrip.stay.until': 'hasta las {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Esta visita tiene hora de fin, así que el viaje sale a las {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'El día de viaje termina antes de las {time}, así que el viaje sigue a la mañana siguiente.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Quitar la hora de fin',
|
||||||
'roadtrip.alt.noMotorway': 'Sin autopista',
|
'roadtrip.alt.noMotorway': 'Sin autopista',
|
||||||
'roadtrip.alt.noToll': 'Sin peajes',
|
'roadtrip.alt.noToll': 'Sin peajes',
|
||||||
'roadtrip.alt.noFerry': 'Sin ferri',
|
'roadtrip.alt.noFerry': 'Sin ferri',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Temps sur place',
|
'roadtrip.stop.stay': 'Temps sur place',
|
||||||
'roadtrip.stop.pinned': 'Heure que vous avez fixée',
|
'roadtrip.stop.pinned': 'Heure que vous avez fixée',
|
||||||
'roadtrip.stop.computed': 'Calculé d’après le trajet',
|
'roadtrip.stop.computed': 'Calculé d’après le trajet',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Pendant le road trip, le départ se fait à cette heure.',
|
||||||
'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer',
|
'roadtrip.via.hint': 'Faites glisser pour remodeler l’itinéraire, clic droit pour supprimer',
|
||||||
'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée',
|
'roadtrip.warn.late': 'Arrivée {minutes} min après l’heure fixée',
|
||||||
|
'roadtrip.warn.missedLeave': 'Arrivée {minutes} min après l’heure de départ fixée',
|
||||||
'roadtrip.warn.overnight': 'Jour suivant',
|
'roadtrip.warn.overnight': 'Jour suivant',
|
||||||
'roadtrip.spill.title': 'Du jour {number}',
|
'roadtrip.spill.title': 'Du jour {number}',
|
||||||
'roadtrip.spill.departs': 'départ {time}',
|
'roadtrip.spill.departs': 'départ {time}',
|
||||||
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minutes',
|
'roadtrip.stay.custom': 'Minutes',
|
||||||
'roadtrip.stay.arrive': 'Arrivee',
|
'roadtrip.stay.arrive': 'Arrivee',
|
||||||
'roadtrip.stay.leave': 'Depart',
|
'roadtrip.stay.leave': 'Depart',
|
||||||
|
'roadtrip.stay.until': 'jusqu’à {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Cette visite a une heure de fin, le trajet repart donc à {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'La journée de route se termine avant {time}, le trajet reprend donc le lendemain matin.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Supprimer l’heure de fin',
|
||||||
'roadtrip.alt.noMotorway': 'Sans autoroute',
|
'roadtrip.alt.noMotorway': 'Sans autoroute',
|
||||||
'roadtrip.alt.noToll': 'Sans peage',
|
'roadtrip.alt.noToll': 'Sans peage',
|
||||||
'roadtrip.alt.noFerry': 'Sans ferry',
|
'roadtrip.alt.noFerry': 'Sans ferry',
|
||||||
|
|||||||
@@ -247,8 +247,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
|
'roadtrip.stop.stay': 'Χρόνος σε αυτή τη στάση',
|
||||||
'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
|
'roadtrip.stop.pinned': 'Ώρα που ορίσατε',
|
||||||
'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
|
'roadtrip.stop.computed': 'Υπολογισμένη από τη διαδρομή',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Στο οδικό ταξίδι, η αναχώρηση γίνεται αυτή την ώρα.',
|
||||||
'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση',
|
'roadtrip.via.hint': 'Σύρετε για να αλλάξετε τη διαδρομή, δεξί κλικ για αφαίρεση',
|
||||||
'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
|
'roadtrip.warn.late': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα',
|
||||||
|
'roadtrip.warn.missedLeave': 'Άφιξη {minutes} λεπτά μετά την ορισμένη ώρα αναχώρησης',
|
||||||
'roadtrip.warn.overnight': 'Επόμενη μέρα',
|
'roadtrip.warn.overnight': 'Επόμενη μέρα',
|
||||||
'roadtrip.spill.title': 'Από την ημέρα {number}',
|
'roadtrip.spill.title': 'Από την ημέρα {number}',
|
||||||
'roadtrip.spill.departs': 'αναχώρηση {time}',
|
'roadtrip.spill.departs': 'αναχώρηση {time}',
|
||||||
@@ -258,6 +260,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Λεπτα',
|
'roadtrip.stay.custom': 'Λεπτα',
|
||||||
'roadtrip.stay.arrive': 'Αφιξη',
|
'roadtrip.stay.arrive': 'Αφιξη',
|
||||||
'roadtrip.stay.leave': 'Αναχωρηση',
|
'roadtrip.stay.leave': 'Αναχωρηση',
|
||||||
|
'roadtrip.stay.until': 'έως {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Αυτή η επίσκεψη έχει ώρα λήξης, οπότε η διαδρομή συνεχίζει στις {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'Η ημέρα ταξιδιού τελειώνει πριν από τις {time}, οπότε η διαδρομή συνεχίζει το επόμενο πρωί.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Αφαίρεση ώρας λήξης',
|
||||||
'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο',
|
'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο',
|
||||||
'roadtrip.alt.noToll': 'Χωρις διοδια',
|
'roadtrip.alt.noToll': 'Χωρις διοδια',
|
||||||
'roadtrip.alt.noFerry': 'Χωρίς φέρι',
|
'roadtrip.alt.noFerry': 'Χωρίς φέρι',
|
||||||
|
|||||||
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Idő ezen a megállón',
|
'roadtrip.stop.stay': 'Idő ezen a megállón',
|
||||||
'roadtrip.stop.pinned': 'Az általad megadott időpont',
|
'roadtrip.stop.pinned': 'Az általad megadott időpont',
|
||||||
'roadtrip.stop.computed': 'A menetidőből számolva',
|
'roadtrip.stop.computed': 'A menetidőből számolva',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Az autós úton ekkor indul tovább az út.',
|
||||||
'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható',
|
'roadtrip.via.hint': 'Húzza az útvonal átalakításához, jobb gombbal eltávolítható',
|
||||||
'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
|
'roadtrip.warn.late': 'Érkezés {minutes} perccel a megadott idő után',
|
||||||
|
'roadtrip.warn.missedLeave': 'Érkezés {minutes} perccel a megadott indulási idő után',
|
||||||
'roadtrip.warn.overnight': 'Következő nap',
|
'roadtrip.warn.overnight': 'Következő nap',
|
||||||
'roadtrip.spill.title': 'A(z) {number}. napról',
|
'roadtrip.spill.title': 'A(z) {number}. napról',
|
||||||
'roadtrip.spill.departs': 'indulás {time}',
|
'roadtrip.spill.departs': 'indulás {time}',
|
||||||
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Perc',
|
'roadtrip.stay.custom': 'Perc',
|
||||||
'roadtrip.stay.arrive': 'Erkezes',
|
'roadtrip.stay.arrive': 'Erkezes',
|
||||||
'roadtrip.stay.leave': 'Indulas',
|
'roadtrip.stay.leave': 'Indulas',
|
||||||
|
'roadtrip.stay.until': '{time}-ig',
|
||||||
|
'roadtrip.stay.leavesAt': 'Ennek a látogatásnak van befejezési ideje, ezért az út {time}-kor folytatódik.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Az utazási nap {time} előtt véget ér, ezért az út másnap reggel folytatódik.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Befejezési idő törlése',
|
||||||
'roadtrip.alt.noMotorway': 'Autopalya nelkul',
|
'roadtrip.alt.noMotorway': 'Autopalya nelkul',
|
||||||
'roadtrip.alt.noToll': 'Dijmentes',
|
'roadtrip.alt.noToll': 'Dijmentes',
|
||||||
'roadtrip.alt.noFerry': 'Komp nélkül',
|
'roadtrip.alt.noFerry': 'Komp nélkül',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Waktu di perhentian ini',
|
'roadtrip.stop.stay': 'Waktu di perhentian ini',
|
||||||
'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
|
'roadtrip.stop.pinned': 'Waktu yang Anda tetapkan',
|
||||||
'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
|
'roadtrip.stop.computed': 'Dihitung dari waktu berkendara',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Dalam perjalanan darat, keberangkatan pada waktu ini.',
|
||||||
'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus',
|
'roadtrip.via.hint': 'Seret untuk mengubah rute, klik kanan untuk menghapus',
|
||||||
'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
|
'roadtrip.warn.late': 'Tiba {minutes} menit setelah waktu yang ditetapkan',
|
||||||
|
'roadtrip.warn.missedLeave': 'Tiba {minutes} menit setelah waktu berangkat yang ditetapkan',
|
||||||
'roadtrip.warn.overnight': 'Hari berikutnya',
|
'roadtrip.warn.overnight': 'Hari berikutnya',
|
||||||
'roadtrip.spill.title': 'Dari hari {number}',
|
'roadtrip.spill.title': 'Dari hari {number}',
|
||||||
'roadtrip.spill.departs': 'berangkat {time}',
|
'roadtrip.spill.departs': 'berangkat {time}',
|
||||||
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Menit',
|
'roadtrip.stay.custom': 'Menit',
|
||||||
'roadtrip.stay.arrive': 'Tiba',
|
'roadtrip.stay.arrive': 'Tiba',
|
||||||
'roadtrip.stay.leave': 'Berangkat',
|
'roadtrip.stay.leave': 'Berangkat',
|
||||||
|
'roadtrip.stay.until': 'sampai {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Kunjungan ini punya waktu selesai, jadi perjalanan berlanjut pukul {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'Hari perjalanan berakhir sebelum pukul {time}, jadi perjalanan berlanjut keesokan paginya.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Hapus waktu selesai',
|
||||||
'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat',
|
'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat',
|
||||||
'roadtrip.alt.noToll': 'Tanpa tol',
|
'roadtrip.alt.noToll': 'Tanpa tol',
|
||||||
'roadtrip.alt.noFerry': 'Tanpa feri',
|
'roadtrip.alt.noFerry': 'Tanpa feri',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Tempo alla sosta',
|
'roadtrip.stop.stay': 'Tempo alla sosta',
|
||||||
'roadtrip.stop.pinned': 'Orario impostato da te',
|
'roadtrip.stop.pinned': 'Orario impostato da te',
|
||||||
'roadtrip.stop.computed': 'Calcolato dal tragitto',
|
'roadtrip.stop.computed': 'Calcolato dal tragitto',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Nel viaggio su strada si riparte a quest’ora.',
|
||||||
'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere',
|
'roadtrip.via.hint': 'Trascina per rimodellare il percorso, clic destro per rimuovere',
|
||||||
'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
|
'roadtrip.warn.late': 'Arrivo {minutes} min dopo l’orario impostato',
|
||||||
|
'roadtrip.warn.missedLeave': 'Arrivo {minutes} min dopo l’orario di partenza impostato',
|
||||||
'roadtrip.warn.overnight': 'Giorno successivo',
|
'roadtrip.warn.overnight': 'Giorno successivo',
|
||||||
'roadtrip.spill.title': 'Dal giorno {number}',
|
'roadtrip.spill.title': 'Dal giorno {number}',
|
||||||
'roadtrip.spill.departs': 'partenza {time}',
|
'roadtrip.spill.departs': 'partenza {time}',
|
||||||
@@ -256,6 +258,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuti',
|
'roadtrip.stay.custom': 'Minuti',
|
||||||
'roadtrip.stay.arrive': 'Arrivo',
|
'roadtrip.stay.arrive': 'Arrivo',
|
||||||
'roadtrip.stay.leave': 'Partenza',
|
'roadtrip.stay.leave': 'Partenza',
|
||||||
|
'roadtrip.stay.until': 'fino alle {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Questa visita ha un orario di fine, quindi il viaggio riparte alle {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'La giornata di viaggio finisce prima delle {time}, quindi il viaggio riparte la mattina dopo.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Rimuovi l’orario di fine',
|
||||||
'roadtrip.alt.noMotorway': 'Senza autostrada',
|
'roadtrip.alt.noMotorway': 'Senza autostrada',
|
||||||
'roadtrip.alt.noToll': 'Senza pedaggi',
|
'roadtrip.alt.noToll': 'Senza pedaggi',
|
||||||
'roadtrip.alt.noFerry': 'Senza traghetto',
|
'roadtrip.alt.noFerry': 'Senza traghetto',
|
||||||
|
|||||||
@@ -241,8 +241,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
|
'roadtrip.stop.stay': 'この立ち寄り先での滞在時間',
|
||||||
'roadtrip.stop.pinned': '設定した時刻',
|
'roadtrip.stop.pinned': '設定した時刻',
|
||||||
'roadtrip.stop.computed': '運転時間からの計算値',
|
'roadtrip.stop.computed': '運転時間からの計算値',
|
||||||
|
'roadtrip.stop.endIsLeave': 'ロードトリップでは、この時刻に出発します。',
|
||||||
'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除',
|
'roadtrip.via.hint': 'ドラッグでルートを変え、右クリックで削除',
|
||||||
'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
|
'roadtrip.warn.late': '設定した時刻より {minutes} 分遅く到着します',
|
||||||
|
'roadtrip.warn.missedLeave': '設定した出発時刻より {minutes} 分遅く到着します',
|
||||||
'roadtrip.warn.overnight': '翌日',
|
'roadtrip.warn.overnight': '翌日',
|
||||||
'roadtrip.spill.title': '{number}日目から',
|
'roadtrip.spill.title': '{number}日目から',
|
||||||
'roadtrip.spill.departs': '{time} 発',
|
'roadtrip.spill.departs': '{time} 発',
|
||||||
@@ -252,6 +254,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': '分',
|
'roadtrip.stay.custom': '分',
|
||||||
'roadtrip.stay.arrive': '到着',
|
'roadtrip.stay.arrive': '到着',
|
||||||
'roadtrip.stay.leave': '出発',
|
'roadtrip.stay.leave': '出発',
|
||||||
|
'roadtrip.stay.until': '{time} まで',
|
||||||
|
'roadtrip.stay.leavesAt': 'この訪問には終了時刻があるため、{time} に出発します。',
|
||||||
|
'roadtrip.stay.dayEndsFirst': '移動時間が {time} より前に終わるため、翌朝に出発します。',
|
||||||
|
'roadtrip.stay.clearLeave': '終了時刻を削除',
|
||||||
'roadtrip.alt.noMotorway': '高速道路なし',
|
'roadtrip.alt.noMotorway': '高速道路なし',
|
||||||
'roadtrip.alt.noToll': '有料道路なし',
|
'roadtrip.alt.noToll': '有料道路なし',
|
||||||
'roadtrip.alt.noFerry': 'フェリーなし',
|
'roadtrip.alt.noFerry': 'フェリーなし',
|
||||||
|
|||||||
@@ -239,8 +239,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': '이 경유지 체류 시간',
|
'roadtrip.stop.stay': '이 경유지 체류 시간',
|
||||||
'roadtrip.stop.pinned': '직접 지정한 시간',
|
'roadtrip.stop.pinned': '직접 지정한 시간',
|
||||||
'roadtrip.stop.computed': '주행 시간으로 계산됨',
|
'roadtrip.stop.computed': '주행 시간으로 계산됨',
|
||||||
|
'roadtrip.stop.endIsLeave': '로드트립에서는 이 시간에 출발합니다.',
|
||||||
'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제',
|
'roadtrip.via.hint': '끌어서 경로를 바꾸고, 오른쪽 클릭으로 삭제',
|
||||||
'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
|
'roadtrip.warn.late': '지정한 시간보다 {minutes}분 늦게 도착',
|
||||||
|
'roadtrip.warn.missedLeave': '지정한 출발 시간보다 {minutes}분 늦게 도착',
|
||||||
'roadtrip.warn.overnight': '다음 날',
|
'roadtrip.warn.overnight': '다음 날',
|
||||||
'roadtrip.spill.title': '{number}일차에서',
|
'roadtrip.spill.title': '{number}일차에서',
|
||||||
'roadtrip.spill.departs': '{time} 출발',
|
'roadtrip.spill.departs': '{time} 출발',
|
||||||
@@ -250,6 +252,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': '분',
|
'roadtrip.stay.custom': '분',
|
||||||
'roadtrip.stay.arrive': '도착',
|
'roadtrip.stay.arrive': '도착',
|
||||||
'roadtrip.stay.leave': '출발',
|
'roadtrip.stay.leave': '출발',
|
||||||
|
'roadtrip.stay.until': '{time}까지',
|
||||||
|
'roadtrip.stay.leavesAt': '이 방문에는 종료 시간이 있어 {time}에 출발합니다.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': '이동 시간이 {time} 전에 끝나므로 다음 날 아침에 출발합니다.',
|
||||||
|
'roadtrip.stay.clearLeave': '종료 시간 삭제',
|
||||||
'roadtrip.alt.noMotorway': '고속도로 제외',
|
'roadtrip.alt.noMotorway': '고속도로 제외',
|
||||||
'roadtrip.alt.noToll': '통행료 없음',
|
'roadtrip.alt.noToll': '통행료 없음',
|
||||||
'roadtrip.alt.noFerry': '페리 없음',
|
'roadtrip.alt.noFerry': '페리 없음',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Tijd bij deze stop',
|
'roadtrip.stop.stay': 'Tijd bij deze stop',
|
||||||
'roadtrip.stop.pinned': 'Door jou gezette tijd',
|
'roadtrip.stop.pinned': 'Door jou gezette tijd',
|
||||||
'roadtrip.stop.computed': 'Berekend uit de rit',
|
'roadtrip.stop.computed': 'Berekend uit de rit',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Op de roadtrip vertrek je op dit tijdstip.',
|
||||||
'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen',
|
'roadtrip.via.hint': 'Sleep om de route te hervormen, rechtsklik om te verwijderen',
|
||||||
'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
|
'roadtrip.warn.late': 'Aankomst {minutes} min na de gezette tijd',
|
||||||
|
'roadtrip.warn.missedLeave': 'Aankomst {minutes} min na de gezette vertrektijd',
|
||||||
'roadtrip.warn.overnight': 'Volgende dag',
|
'roadtrip.warn.overnight': 'Volgende dag',
|
||||||
'roadtrip.spill.title': 'Van dag {number}',
|
'roadtrip.spill.title': 'Van dag {number}',
|
||||||
'roadtrip.spill.departs': 'vertrek {time}',
|
'roadtrip.spill.departs': 'vertrek {time}',
|
||||||
@@ -256,6 +258,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuten',
|
'roadtrip.stay.custom': 'Minuten',
|
||||||
'roadtrip.stay.arrive': 'Aankomst',
|
'roadtrip.stay.arrive': 'Aankomst',
|
||||||
'roadtrip.stay.leave': 'Vertrek',
|
'roadtrip.stay.leave': 'Vertrek',
|
||||||
|
'roadtrip.stay.until': 'tot {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Dit bezoek heeft een eindtijd, dus de rit gaat om {time} verder.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'De reisdag eindigt vóór {time}, dus de rit gaat de volgende ochtend verder.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Eindtijd verwijderen',
|
||||||
'roadtrip.alt.noMotorway': 'Zonder snelweg',
|
'roadtrip.alt.noMotorway': 'Zonder snelweg',
|
||||||
'roadtrip.alt.noToll': 'Zonder tol',
|
'roadtrip.alt.noToll': 'Zonder tol',
|
||||||
'roadtrip.alt.noFerry': 'Zonder veerboot',
|
'roadtrip.alt.noFerry': 'Zonder veerboot',
|
||||||
|
|||||||
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Czas na tym przystanku',
|
'roadtrip.stop.stay': 'Czas na tym przystanku',
|
||||||
'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
|
'roadtrip.stop.pinned': 'Godzina ustawiona przez Ciebie',
|
||||||
'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
|
'roadtrip.stop.computed': 'Obliczone na podstawie jazdy',
|
||||||
|
'roadtrip.stop.endIsLeave': 'W podróży samochodowej odjazd następuje o tej godzinie.',
|
||||||
'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć',
|
'roadtrip.via.hint': 'Przeciągnij, aby zmienić trasę, kliknij prawym, aby usunąć',
|
||||||
'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
|
'roadtrip.warn.late': 'Przyjazd {minutes} min po ustawionej godzinie',
|
||||||
|
'roadtrip.warn.missedLeave': 'Przyjazd {minutes} min po ustawionej godzinie odjazdu',
|
||||||
'roadtrip.warn.overnight': 'Następny dzień',
|
'roadtrip.warn.overnight': 'Następny dzień',
|
||||||
'roadtrip.spill.title': 'Z dnia {number}',
|
'roadtrip.spill.title': 'Z dnia {number}',
|
||||||
'roadtrip.spill.departs': 'odjazd {time}',
|
'roadtrip.spill.departs': 'odjazd {time}',
|
||||||
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuty',
|
'roadtrip.stay.custom': 'Minuty',
|
||||||
'roadtrip.stay.arrive': 'Przyjazd',
|
'roadtrip.stay.arrive': 'Przyjazd',
|
||||||
'roadtrip.stay.leave': 'Odjazd',
|
'roadtrip.stay.leave': 'Odjazd',
|
||||||
|
'roadtrip.stay.until': 'do {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Ta wizyta ma godzinę zakończenia, więc podróż rusza dalej o {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Dzień podróży kończy się przed {time}, więc podróż rusza dalej następnego ranka.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Usuń godzinę zakończenia',
|
||||||
'roadtrip.alt.noMotorway': 'Bez autostrady',
|
'roadtrip.alt.noMotorway': 'Bez autostrady',
|
||||||
'roadtrip.alt.noToll': 'Bez oplat',
|
'roadtrip.alt.noToll': 'Bez oplat',
|
||||||
'roadtrip.alt.noFerry': 'Bez promu',
|
'roadtrip.alt.noFerry': 'Bez promu',
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Время на остановке',
|
'roadtrip.stop.stay': 'Время на остановке',
|
||||||
'roadtrip.stop.pinned': 'Заданное вами время',
|
'roadtrip.stop.pinned': 'Заданное вами время',
|
||||||
'roadtrip.stop.computed': 'Рассчитано по времени в пути',
|
'roadtrip.stop.computed': 'Рассчитано по времени в пути',
|
||||||
|
'roadtrip.stop.endIsLeave': 'В автопутешествии отправление будет в это время.',
|
||||||
'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить',
|
'roadtrip.via.hint': 'Перетащите, чтобы изменить маршрут, правый клик — удалить',
|
||||||
'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
|
'roadtrip.warn.late': 'Прибытие на {minutes} мин позже заданного времени',
|
||||||
|
'roadtrip.warn.missedLeave': 'Прибытие на {minutes} мин позже заданного времени отправления',
|
||||||
'roadtrip.warn.overnight': 'Следующий день',
|
'roadtrip.warn.overnight': 'Следующий день',
|
||||||
'roadtrip.spill.title': 'Со дня {number}',
|
'roadtrip.spill.title': 'Со дня {number}',
|
||||||
'roadtrip.spill.departs': 'выезд {time}',
|
'roadtrip.spill.departs': 'выезд {time}',
|
||||||
@@ -256,6 +258,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Минуты',
|
'roadtrip.stay.custom': 'Минуты',
|
||||||
'roadtrip.stay.arrive': 'Прибытие',
|
'roadtrip.stay.arrive': 'Прибытие',
|
||||||
'roadtrip.stay.leave': 'Отправление',
|
'roadtrip.stay.leave': 'Отправление',
|
||||||
|
'roadtrip.stay.until': 'до {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'У этого визита есть время окончания, поэтому поездка продолжится в {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'День в пути заканчивается раньше {time}, поэтому поездка продолжится следующим утром.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Убрать время окончания',
|
||||||
'roadtrip.alt.noMotorway': 'Без автомагистрали',
|
'roadtrip.alt.noMotorway': 'Без автомагистрали',
|
||||||
'roadtrip.alt.noToll': 'Без платных дорог',
|
'roadtrip.alt.noToll': 'Без платных дорог',
|
||||||
'roadtrip.alt.noFerry': 'Без парома',
|
'roadtrip.alt.noFerry': 'Без парома',
|
||||||
|
|||||||
@@ -242,8 +242,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Tid på stoppet',
|
'roadtrip.stop.stay': 'Tid på stoppet',
|
||||||
'roadtrip.stop.pinned': 'Tid du har satt',
|
'roadtrip.stop.pinned': 'Tid du har satt',
|
||||||
'roadtrip.stop.computed': 'Beräknad utifrån körningen',
|
'roadtrip.stop.computed': 'Beräknad utifrån körningen',
|
||||||
|
'roadtrip.stop.endIsLeave': 'På bilresan åker ni vidare vid den här tiden.',
|
||||||
'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort',
|
'roadtrip.via.hint': 'Dra för att forma om rutten, högerklicka för att ta bort',
|
||||||
'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
|
'roadtrip.warn.late': 'Ankomst {minutes} min efter den satta tiden',
|
||||||
|
'roadtrip.warn.missedLeave': 'Ankomst {minutes} min efter den satta avfärdstiden',
|
||||||
'roadtrip.warn.overnight': 'Nästa dag',
|
'roadtrip.warn.overnight': 'Nästa dag',
|
||||||
'roadtrip.spill.title': 'Från dag {number}',
|
'roadtrip.spill.title': 'Från dag {number}',
|
||||||
'roadtrip.spill.departs': 'avgång {time}',
|
'roadtrip.spill.departs': 'avgång {time}',
|
||||||
@@ -253,6 +255,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Minuter',
|
'roadtrip.stay.custom': 'Minuter',
|
||||||
'roadtrip.stay.arrive': 'Ankomst',
|
'roadtrip.stay.arrive': 'Ankomst',
|
||||||
'roadtrip.stay.leave': 'Avfard',
|
'roadtrip.stay.leave': 'Avfard',
|
||||||
|
'roadtrip.stay.until': 'till {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Besöket har en sluttid, så resan fortsätter {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Resdagen tar slut före {time}, så resan fortsätter nästa morgon.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Ta bort sluttid',
|
||||||
'roadtrip.alt.noMotorway': 'Utan motorvag',
|
'roadtrip.alt.noMotorway': 'Utan motorvag',
|
||||||
'roadtrip.alt.noToll': 'Utan vagavgifter',
|
'roadtrip.alt.noToll': 'Utan vagavgifter',
|
||||||
'roadtrip.alt.noFerry': 'Utan färja',
|
'roadtrip.alt.noFerry': 'Utan färja',
|
||||||
|
|||||||
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Bu duraktaki süre',
|
'roadtrip.stop.stay': 'Bu duraktaki süre',
|
||||||
'roadtrip.stop.pinned': 'Belirlediğiniz saat',
|
'roadtrip.stop.pinned': 'Belirlediğiniz saat',
|
||||||
'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
|
'roadtrip.stop.computed': 'Sürüşe göre hesaplandı',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Yol gezisinde bu saatte yola çıkılır.',
|
||||||
'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın',
|
'roadtrip.via.hint': 'Rotayı yeniden şekillendirmek için sürükleyin, kaldırmak için sağ tıklayın',
|
||||||
'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
|
'roadtrip.warn.late': 'Belirlenen saatten {minutes} dk sonra varış',
|
||||||
|
'roadtrip.warn.missedLeave': 'Belirlenen kalkış saatinden {minutes} dk sonra varış',
|
||||||
'roadtrip.warn.overnight': 'Ertesi gün',
|
'roadtrip.warn.overnight': 'Ertesi gün',
|
||||||
'roadtrip.spill.title': '{number}. günden',
|
'roadtrip.spill.title': '{number}. günden',
|
||||||
'roadtrip.spill.departs': 'kalkış {time}',
|
'roadtrip.spill.departs': 'kalkış {time}',
|
||||||
@@ -255,6 +257,11 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Dakika',
|
'roadtrip.stay.custom': 'Dakika',
|
||||||
'roadtrip.stay.arrive': 'Varis',
|
'roadtrip.stay.arrive': 'Varis',
|
||||||
'roadtrip.stay.leave': 'Kalkis',
|
'roadtrip.stay.leave': 'Kalkis',
|
||||||
|
'roadtrip.stay.until': '{time} saatine kadar',
|
||||||
|
'roadtrip.stay.leavesAt': 'Bu ziyaretin bitiş saati var, bu yüzden yolculuk {time} saatinde devam eder.',
|
||||||
|
'roadtrip.stay.dayEndsFirst':
|
||||||
|
'Yolculuk günü {time} saatinden önce bitiyor, bu yüzden yolculuk ertesi sabah devam eder.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Bitiş saatini kaldır',
|
||||||
'roadtrip.alt.noMotorway': 'Otoyolsuz',
|
'roadtrip.alt.noMotorway': 'Otoyolsuz',
|
||||||
'roadtrip.alt.noToll': 'Ucretsiz',
|
'roadtrip.alt.noToll': 'Ucretsiz',
|
||||||
'roadtrip.alt.noFerry': 'Feribotsuz',
|
'roadtrip.alt.noFerry': 'Feribotsuz',
|
||||||
|
|||||||
@@ -244,8 +244,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Час на зупинці',
|
'roadtrip.stop.stay': 'Час на зупинці',
|
||||||
'roadtrip.stop.pinned': 'Час, який ви задали',
|
'roadtrip.stop.pinned': 'Час, який ви задали',
|
||||||
'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
|
'roadtrip.stop.computed': 'Розраховано за часом у дорозі',
|
||||||
|
'roadtrip.stop.endIsLeave': 'В автоподорожі відʼїзд відбувається в цей час.',
|
||||||
'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити',
|
'roadtrip.via.hint': 'Перетягніть, щоб змінити маршрут, права кнопка — видалити',
|
||||||
'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
|
'roadtrip.warn.late': 'Прибуття на {minutes} хв пізніше заданого часу',
|
||||||
|
'roadtrip.warn.missedLeave': 'Прибуття на {minutes} хв пізніше заданого часу відʼїзду',
|
||||||
'roadtrip.warn.overnight': 'Наступний день',
|
'roadtrip.warn.overnight': 'Наступний день',
|
||||||
'roadtrip.spill.title': 'З дня {number}',
|
'roadtrip.spill.title': 'З дня {number}',
|
||||||
'roadtrip.spill.departs': 'виїзд {time}',
|
'roadtrip.spill.departs': 'виїзд {time}',
|
||||||
@@ -255,6 +257,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Хвилини',
|
'roadtrip.stay.custom': 'Хвилини',
|
||||||
'roadtrip.stay.arrive': 'Прибуття',
|
'roadtrip.stay.arrive': 'Прибуття',
|
||||||
'roadtrip.stay.leave': 'Відʼїзд',
|
'roadtrip.stay.leave': 'Відʼїзд',
|
||||||
|
'roadtrip.stay.until': 'до {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Цей візит має час завершення, тому поїздка продовжиться о {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'День подорожі закінчується раніше {time}, тому поїздка продовжиться наступного ранку.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Прибрати час завершення',
|
||||||
'roadtrip.alt.noMotorway': 'Без автомагістралі',
|
'roadtrip.alt.noMotorway': 'Без автомагістралі',
|
||||||
'roadtrip.alt.noToll': 'Без платних доріг',
|
'roadtrip.alt.noToll': 'Без платних доріг',
|
||||||
'roadtrip.alt.noFerry': 'Без порома',
|
'roadtrip.alt.noFerry': 'Без порома',
|
||||||
|
|||||||
@@ -243,8 +243,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': 'Thời gian tại điểm dừng',
|
'roadtrip.stop.stay': 'Thời gian tại điểm dừng',
|
||||||
'roadtrip.stop.pinned': 'Giờ bạn đặt',
|
'roadtrip.stop.pinned': 'Giờ bạn đặt',
|
||||||
'roadtrip.stop.computed': 'Tính từ thời gian lái xe',
|
'roadtrip.stop.computed': 'Tính từ thời gian lái xe',
|
||||||
|
'roadtrip.stop.endIsLeave': 'Trong chuyến đi đường bộ, sẽ khởi hành vào giờ này.',
|
||||||
'roadtrip.via.hint': 'Kéo để định hình lại lộ trình, chuột phải để xóa',
|
'roadtrip.via.hint': 'Kéo để định hình lại lộ trình, chuột phải để xóa',
|
||||||
'roadtrip.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt',
|
'roadtrip.warn.late': 'Đến muộn {minutes} phút so với giờ đã đặt',
|
||||||
|
'roadtrip.warn.missedLeave': 'Đến muộn {minutes} phút so với giờ rời đi đã đặt',
|
||||||
'roadtrip.warn.overnight': 'Ngày hôm sau',
|
'roadtrip.warn.overnight': 'Ngày hôm sau',
|
||||||
'roadtrip.spill.title': 'Từ ngày {number}',
|
'roadtrip.spill.title': 'Từ ngày {number}',
|
||||||
'roadtrip.spill.departs': 'khởi hành {time}',
|
'roadtrip.spill.departs': 'khởi hành {time}',
|
||||||
@@ -254,6 +256,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': 'Phut',
|
'roadtrip.stay.custom': 'Phut',
|
||||||
'roadtrip.stay.arrive': 'Den noi',
|
'roadtrip.stay.arrive': 'Den noi',
|
||||||
'roadtrip.stay.leave': 'Roi di',
|
'roadtrip.stay.leave': 'Roi di',
|
||||||
|
'roadtrip.stay.until': 'đến {time}',
|
||||||
|
'roadtrip.stay.leavesAt': 'Lượt ghé này có giờ kết thúc, nên chuyến đi tiếp tục lúc {time}.',
|
||||||
|
'roadtrip.stay.dayEndsFirst': 'Ngày di chuyển kết thúc trước {time}, nên chuyến đi tiếp tục vào sáng hôm sau.',
|
||||||
|
'roadtrip.stay.clearLeave': 'Xóa giờ kết thúc',
|
||||||
'roadtrip.alt.noMotorway': 'Khong di cao toc',
|
'roadtrip.alt.noMotorway': 'Khong di cao toc',
|
||||||
'roadtrip.alt.noToll': 'Khong thu phi',
|
'roadtrip.alt.noToll': 'Khong thu phi',
|
||||||
'roadtrip.alt.noFerry': 'Không phà',
|
'roadtrip.alt.noFerry': 'Không phà',
|
||||||
|
|||||||
@@ -230,8 +230,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': '在此停留時間',
|
'roadtrip.stop.stay': '在此停留時間',
|
||||||
'roadtrip.stop.pinned': '你設定的時間',
|
'roadtrip.stop.pinned': '你設定的時間',
|
||||||
'roadtrip.stop.computed': '依行駛時間推算',
|
'roadtrip.stop.computed': '依行駛時間推算',
|
||||||
|
'roadtrip.stop.endIsLeave': '在公路旅行中,將在此時間出發。',
|
||||||
'roadtrip.via.hint': '拖曳可改變路線,右鍵刪除',
|
'roadtrip.via.hint': '拖曳可改變路線,右鍵刪除',
|
||||||
'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘',
|
'roadtrip.warn.late': '比設定時間晚到 {minutes} 分鐘',
|
||||||
|
'roadtrip.warn.missedLeave': '比設定的出發時間晚到 {minutes} 分鐘',
|
||||||
'roadtrip.warn.overnight': '次日',
|
'roadtrip.warn.overnight': '次日',
|
||||||
'roadtrip.spill.title': '來自第 {number} 天',
|
'roadtrip.spill.title': '來自第 {number} 天',
|
||||||
'roadtrip.spill.departs': '{time} 出發',
|
'roadtrip.spill.departs': '{time} 出發',
|
||||||
@@ -241,6 +243,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': '分鐘',
|
'roadtrip.stay.custom': '分鐘',
|
||||||
'roadtrip.stay.arrive': '抵達',
|
'roadtrip.stay.arrive': '抵達',
|
||||||
'roadtrip.stay.leave': '出發',
|
'roadtrip.stay.leave': '出發',
|
||||||
|
'roadtrip.stay.until': '到 {time}',
|
||||||
|
'roadtrip.stay.leavesAt': '此行程設有結束時間,因此將在 {time} 出發。',
|
||||||
|
'roadtrip.stay.dayEndsFirst': '當天的出行時間在 {time} 之前結束,因此將於隔天早上繼續出發。',
|
||||||
|
'roadtrip.stay.clearLeave': '移除結束時間',
|
||||||
'roadtrip.alt.noMotorway': '不走高速',
|
'roadtrip.alt.noMotorway': '不走高速',
|
||||||
'roadtrip.alt.noToll': '免收費',
|
'roadtrip.alt.noToll': '免收費',
|
||||||
'roadtrip.alt.noFerry': '不搭渡輪',
|
'roadtrip.alt.noFerry': '不搭渡輪',
|
||||||
|
|||||||
@@ -231,8 +231,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stop.stay': '在此停留时间',
|
'roadtrip.stop.stay': '在此停留时间',
|
||||||
'roadtrip.stop.pinned': '你设定的时间',
|
'roadtrip.stop.pinned': '你设定的时间',
|
||||||
'roadtrip.stop.computed': '根据行驶时间推算',
|
'roadtrip.stop.computed': '根据行驶时间推算',
|
||||||
|
'roadtrip.stop.endIsLeave': '在公路旅行中,将在此时间出发。',
|
||||||
'roadtrip.via.hint': '拖动可改变路线,右键删除',
|
'roadtrip.via.hint': '拖动可改变路线,右键删除',
|
||||||
'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟',
|
'roadtrip.warn.late': '比设定时间晚到 {minutes} 分钟',
|
||||||
|
'roadtrip.warn.missedLeave': '比设定的出发时间晚到 {minutes} 分钟',
|
||||||
'roadtrip.warn.overnight': '次日',
|
'roadtrip.warn.overnight': '次日',
|
||||||
'roadtrip.spill.title': '来自第 {number} 天',
|
'roadtrip.spill.title': '来自第 {number} 天',
|
||||||
'roadtrip.spill.departs': '{time} 出发',
|
'roadtrip.spill.departs': '{time} 出发',
|
||||||
@@ -242,6 +244,10 @@ const roadtrip: TranslationStrings = {
|
|||||||
'roadtrip.stay.custom': '分钟',
|
'roadtrip.stay.custom': '分钟',
|
||||||
'roadtrip.stay.arrive': '到达',
|
'roadtrip.stay.arrive': '到达',
|
||||||
'roadtrip.stay.leave': '出发',
|
'roadtrip.stay.leave': '出发',
|
||||||
|
'roadtrip.stay.until': '到 {time}',
|
||||||
|
'roadtrip.stay.leavesAt': '此行程设有结束时间,因此将在 {time} 出发。',
|
||||||
|
'roadtrip.stay.dayEndsFirst': '当天的出行时间在 {time} 之前结束,因此将于次日早上继续出发。',
|
||||||
|
'roadtrip.stay.clearLeave': '移除结束时间',
|
||||||
'roadtrip.alt.noMotorway': '不走高速',
|
'roadtrip.alt.noMotorway': '不走高速',
|
||||||
'roadtrip.alt.noToll': '免收费',
|
'roadtrip.alt.noToll': '免收费',
|
||||||
'roadtrip.alt.noFerry': '不乘轮渡',
|
'roadtrip.alt.noFerry': '不乘轮渡',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* at the charger, and a run of warnings that should collapse to one did not.
|
* at the charger, and a run of warnings that should collapse to one did not.
|
||||||
*/
|
*/
|
||||||
import { assembleRoadtrip } from './assemble';
|
import { assembleRoadtrip } from './assemble';
|
||||||
|
import type { DayWindow } from './dayWindow';
|
||||||
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
import type { PlanDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
@@ -53,7 +54,7 @@ const stopKey = (s: RoadtripStop): string =>
|
|||||||
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
|
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`;
|
||||||
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`;
|
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`;
|
||||||
|
|
||||||
function assemble(stops: RoadtripStop[], legs: number[]) {
|
function assemble(stops: RoadtripStop[], legs: number[], window: DayWindow | null = null) {
|
||||||
const day: PlanDay = { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops };
|
const day: PlanDay = { dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops };
|
||||||
const allLegs: Record<string, RoutedLeg> = {};
|
const allLegs: Record<string, RoutedLeg> = {};
|
||||||
legs.forEach((km, i) => {
|
legs.forEach((km, i) => {
|
||||||
@@ -62,7 +63,7 @@ function assemble(stops: RoadtripStop[], legs: number[]) {
|
|||||||
return assembleRoadtrip({
|
return assembleRoadtrip({
|
||||||
plan: [day],
|
plan: [day],
|
||||||
quietDays: [],
|
quietDays: [],
|
||||||
window: null,
|
window,
|
||||||
distanceUnit: 'metric',
|
distanceUnit: 'metric',
|
||||||
allLegs,
|
allLegs,
|
||||||
snapByDay: {},
|
snapByDay: {},
|
||||||
@@ -257,3 +258,39 @@ describe('assembleRoadtrip connected days', () => {
|
|||||||
expect(routes.lineJoins).toEqual([false, false, true, false]);
|
expect(routes.lineJoins).toEqual([false, false, true, false]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('assembleRoadtrip leave times', () => {
|
||||||
|
// 90 km at the 40 seconds a kilometre `leg` drives is an hour.
|
||||||
|
const hour = 90;
|
||||||
|
|
||||||
|
it('ROADTRIP-ASSEMBLE-010: a stop left at a set time holds the day until then', () => {
|
||||||
|
const stops = [
|
||||||
|
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
|
||||||
|
stop({ ownerIndex: 1, dwellMinutes: 30, leaveAt: '14:00' }),
|
||||||
|
stop({ ownerIndex: 2 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { schedule } = assemble(stops, [hour, hour]).days[0]!;
|
||||||
|
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('15:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ROADTRIP-ASSEMBLE-011: the plain schedule a conflicting window falls back to still reads it', () => {
|
||||||
|
// Leaving at two with an hour to drive cannot make a stop pinned at half past two,
|
||||||
|
// which is a conflict for the daily window. The day is then scheduled without it, and
|
||||||
|
// that schedule has to keep the departure and report the pin it makes late.
|
||||||
|
const stops = [
|
||||||
|
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
|
||||||
|
stop({ ownerIndex: 1, dwellMinutes: 30, leaveAt: '14:00' }),
|
||||||
|
stop({ ownerIndex: 2, time: '14:30' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const routes = assemble(stops, [hour, hour], { start: 480, end: 1200 });
|
||||||
|
|
||||||
|
expect(routes.dayWindowIssue).toBe('conflict');
|
||||||
|
const { schedule } = routes.days[0]!;
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
|
||||||
|
expect(schedule.warnings).toContainEqual({ index: 2, code: 'late', minutes: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
deriveDriveWarnings,
|
deriveDriveWarnings,
|
||||||
refuelsRange,
|
refuelsRange,
|
||||||
isServiceStopType,
|
isServiceStopType,
|
||||||
|
scheduleStopOf,
|
||||||
type DryPoint,
|
type DryPoint,
|
||||||
type DriveLimits,
|
type DriveLimits,
|
||||||
type VehicleKind,
|
type VehicleKind,
|
||||||
@@ -86,11 +87,7 @@ export function assembleRoadtrip({
|
|||||||
...d,
|
...d,
|
||||||
spills: [],
|
spills: [],
|
||||||
schedule: computeSchedule(
|
schedule: computeSchedule(
|
||||||
d.stops.map((s) => ({
|
d.stops.map(scheduleStopOf),
|
||||||
anchor: s.time ?? null,
|
|
||||||
earliest: s.checkInTime ?? null,
|
|
||||||
dwellMinutes: s.dwellMinutes,
|
|
||||||
})),
|
|
||||||
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
|
d.stops.slice(0, -1).map((s, i) => storedLegFor(s, d.stops[i + 1]!)?.seg.duration),
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -28,4 +28,35 @@ describe('continuing after a moved day group', () => {
|
|||||||
expect(completed.entries[2]).toEqual(original.entries[2]);
|
expect(completed.entries[2]).toEqual(original.entries[2]);
|
||||||
expect(completed.entries[3]!.arrival).toBe('09:00');
|
expect(completed.entries[3]!.arrival).toBe('09:00');
|
||||||
});
|
});
|
||||||
|
it('holds a stop with a leave time until then, and reports one it reaches too late', () => {
|
||||||
|
const leaving = [
|
||||||
|
{ dwellMinutes: 0 },
|
||||||
|
{ dwellMinutes: 60, leaveAt: '23:30' },
|
||||||
|
{ dwellMinutes: 60, leaveAt: '00:30' },
|
||||||
|
{ dwellMinutes: 0 },
|
||||||
|
] as RoadtripStop[];
|
||||||
|
const completed = continueSchedule(leaving, schedule(), [3600, 3600, 3600], 2);
|
||||||
|
// In at 19:15 and out at 23:30, not at 20:15.
|
||||||
|
expect(completed.entries[1]).toMatchObject({ arrival: '19:15', departure: '23:30' });
|
||||||
|
// Reached on the minute it is meant to be left at: gone at once, and not late.
|
||||||
|
expect(completed.entries[2]).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
|
||||||
|
expect(completed.entries[3]!.arrival).toBe('01:30');
|
||||||
|
expect(completed.warnings).not.toContainEqual(expect.objectContaining({ code: 'missedLeave' }));
|
||||||
|
// Another hour on the road and it is reached an hour after it should have been left.
|
||||||
|
const late = continueSchedule(leaving, schedule(), [3600, 7200, 3600], 2);
|
||||||
|
expect(late.warnings).toContainEqual({ index: 2, code: 'missedLeave', minutes: 60 });
|
||||||
|
});
|
||||||
|
it('reports a leave time the drive went past before midnight rather than waiting for the next one', () => {
|
||||||
|
const leaving = [
|
||||||
|
{ dwellMinutes: 0 },
|
||||||
|
{ dwellMinutes: 60, leaveAt: '23:30' },
|
||||||
|
{ dwellMinutes: 60 },
|
||||||
|
{ dwellMinutes: 0 },
|
||||||
|
] as RoadtripStop[];
|
||||||
|
// Out of Munich at 18:15, six and a quarter hours to go: in at half past midnight.
|
||||||
|
const completed = continueSchedule(leaving, schedule(), [375 * 60, 3600, 3600], 2);
|
||||||
|
expect(completed.entries[1]).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
|
||||||
|
expect(completed.entries[2]!.arrival).toBe('01:30');
|
||||||
|
expect(completed.warnings).toContainEqual({ index: 1, code: 'missedLeave', minutes: 60 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RoadtripStop } from './planning-types';
|
import type { RoadtripStop } from './planning-types';
|
||||||
import { formatClock, parseClock, type Schedule } from './roadtripModel';
|
import { formatClock, leaveAfter, parseClock, type Schedule } from './roadtripModel';
|
||||||
|
|
||||||
export function continueSchedule(
|
export function continueSchedule(
|
||||||
stops: RoadtripStop[],
|
stops: RoadtripStop[],
|
||||||
@@ -23,7 +23,10 @@ export function continueSchedule(
|
|||||||
previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
|
previous.dayOffset * 1440 + departure + (previousArrival !== null && departure < previousArrival ? 1440 : 0);
|
||||||
const arrivalAt = departureAt + Math.round(duration / 60);
|
const arrivalAt = departureAt + Math.round(duration / 60);
|
||||||
const stop = stops[i]!;
|
const stop = stops[i]!;
|
||||||
const leaveAt = arrivalAt + (stop.dwellMinutes ?? 0);
|
const leave = parseClock(stop.leaveAt);
|
||||||
|
const left = leave === null ? null : leaveAfter(arrivalAt, leave, departureAt);
|
||||||
|
if (left && left.missedBy !== null) warnings.push({ index: i, code: 'missedLeave', minutes: left.missedBy });
|
||||||
|
const leaveAt = left ? left.departure : arrivalAt + (stop.dwellMinutes ?? 0);
|
||||||
entry.arrival = formatClock(arrivalAt);
|
entry.arrival = formatClock(arrivalAt);
|
||||||
entry.departure = formatClock(leaveAt);
|
entry.departure = formatClock(leaveAt);
|
||||||
entry.dayOffset = Math.floor(arrivalAt / 1440);
|
entry.dayOffset = Math.floor(arrivalAt / 1440);
|
||||||
|
|||||||
@@ -416,3 +416,94 @@ it('waits for check-in without treating it as a fixed appointment', () => {
|
|||||||
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
|
expect(plan.chains[0]!.schedule.entries[1]!.arrival).toBe('15:00');
|
||||||
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
|
expect(plan.chains[0]!.schedule.entries.at(-1)!.arrival).toBe('15:29');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a time set to leave a stop, with daily travel times', () => {
|
||||||
|
it('spends the hours until then rather than the stay', () => {
|
||||||
|
const plan = calculate(
|
||||||
|
[stop(1, { time: '09:00' }), stop(2, { dwellMinutes: 30, leaveAt: '14:00' }), stop(3)],
|
||||||
|
[60, 60],
|
||||||
|
);
|
||||||
|
expect(plan.issue).toBeNull();
|
||||||
|
const entries = plan.chains[0]!.schedule.entries;
|
||||||
|
expect(entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
|
||||||
|
expect(entries[2]!.arrival).toBe('15:00');
|
||||||
|
expect(plan.chains[0]!.schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins both ends of the stay when the visit has a start as well', () => {
|
||||||
|
const plan = calculate(
|
||||||
|
[stop(1, { time: '09:00' }), stop(2, { time: '10:30', dwellMinutes: 60, leaveAt: '14:00' }), stop(3)],
|
||||||
|
[60, 60],
|
||||||
|
);
|
||||||
|
const entries = plan.chains[0]!.schedule.entries;
|
||||||
|
expect(entries[1]!).toMatchObject({ arrival: '10:30', departure: '14:00', anchored: true });
|
||||||
|
expect(entries[2]!.arrival).toBe('15:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves on arrival and says so when the drive gets there after it', () => {
|
||||||
|
const plan = calculate(
|
||||||
|
[stop(1, { time: '13:30' }), stop(2, { dwellMinutes: 30, leaveAt: '14:00' }), stop(3)],
|
||||||
|
[60, 60],
|
||||||
|
);
|
||||||
|
expect(plan.issue).toBeNull();
|
||||||
|
const chain = plan.chains[0]!;
|
||||||
|
expect(chain.schedule.entries[1]!).toMatchObject({ arrival: '14:30', departure: '14:30' });
|
||||||
|
expect(chain.schedule.entries[2]!.arrival).toBe('15:30');
|
||||||
|
expect(chain.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the day early for a first stop left before the travel hours begin', () => {
|
||||||
|
// A ferry at seven on a day whose hours begin at eight. A Start at seven already
|
||||||
|
// started the day then; an End was read against eight and reported an hour missed.
|
||||||
|
const plan = calculate([stop(1, { dwellMinutes: 30, leaveAt: '07:00' }), stop(2)], [60]);
|
||||||
|
expect(plan.issue).toBeNull();
|
||||||
|
const chain = plan.chains[0]!;
|
||||||
|
expect(chain.schedule.entries[0]!).toMatchObject({ arrival: '07:00', departure: '07:00' });
|
||||||
|
expect(chain.schedule.entries[1]!.arrival).toBe('08:00');
|
||||||
|
expect(chain.schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the morning after a night early enough to leave the first stop in time', () => {
|
||||||
|
const a = stop(1);
|
||||||
|
const b = stop(2, { ownerDayId: 2, ownerIndex: 0, leaveAt: '07:00' });
|
||||||
|
const plan = planDayWindow([day(1, [a]), day(2, [b])], hours, (from, to) => leg(60, from, to), 'metric', labels);
|
||||||
|
expect(plan.issue).toBeNull();
|
||||||
|
const morning = plan.chains[1]!;
|
||||||
|
expect(morning.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
|
||||||
|
['06:00', '06:00'],
|
||||||
|
['07:00', '07:00'],
|
||||||
|
]);
|
||||||
|
expect(morning.schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a first stop whose time is inside the travel hours to them', () => {
|
||||||
|
const plan = calculate([stop(1, { dwellMinutes: 30, leaveAt: '09:00' }), stop(2)], [60]);
|
||||||
|
expect(plan.chains[0]!.schedule.entries[0]!).toMatchObject({ arrival: '08:00', departure: '09:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a leave time a drive past midnight went by, on a day ended by hand', () => {
|
||||||
|
// Out at eight in the evening, four and a half hours on the road, the day ended by hand
|
||||||
|
// at the stop: in at half past midnight, an hour after the half past eleven it was
|
||||||
|
// meant to be left at, which is missed rather than the next evening's.
|
||||||
|
const stops = [stop(1, { time: '20:00' }), stop(2, { leaveAt: '23:30' }), stop(3)];
|
||||||
|
const plan = planDayWindow(
|
||||||
|
[day(1, stops), day(2)],
|
||||||
|
hours,
|
||||||
|
(a, b) => leg(a === stops[0] ? 270 : 60, a, b),
|
||||||
|
'metric',
|
||||||
|
labels,
|
||||||
|
[{ day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 }],
|
||||||
|
);
|
||||||
|
expect(plan.issue).toBeNull();
|
||||||
|
const chain = plan.chains[0]!;
|
||||||
|
expect(chain.schedule.entries[1]!).toMatchObject({ arrival: '00:30', departure: '00:30' });
|
||||||
|
expect(chain.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 60 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hands the leave time on to the night it closes the day with', () => {
|
||||||
|
const plan = calculate([stop(1, { leaveAt: '09:00' }), stop(2, { lng: 12 })]);
|
||||||
|
const night = plan.chains[0]!.stops.find((s) => s.automaticNight);
|
||||||
|
expect(night).toBeDefined();
|
||||||
|
expect(night!.leaveAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RoadtripDayBoundary } from './day-boundary.schema';
|
|||||||
import type { SpillChain } from './nightSpill';
|
import type { SpillChain } from './nightSpill';
|
||||||
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types';
|
import type { RoadtripDay, RoadtripStop, RoutedLeg } from './planning-types';
|
||||||
import type { DistanceUnit } from './planning-types';
|
import type { DistanceUnit } from './planning-types';
|
||||||
import { formatClock, hasChosenArrival, parseClock } from './roadtripModel';
|
import { formatClock, hasChosenArrival, leaveAfter, parseClock } from './roadtripModel';
|
||||||
import { formatDurationShort } from './roadtripModel';
|
import { formatDurationShort } from './roadtripModel';
|
||||||
import { formatDistance } from './units';
|
import { formatDistance } from './units';
|
||||||
|
|
||||||
@@ -180,6 +180,7 @@ export function planDayWindow(
|
|||||||
assignmentId: -2000000000 - number * 2,
|
assignmentId: -2000000000 - number * 2,
|
||||||
name: labels.end,
|
name: labels.end,
|
||||||
time: null,
|
time: null,
|
||||||
|
leaveAt: undefined,
|
||||||
dwellMinutes: 0,
|
dwellMinutes: 0,
|
||||||
checkInTime: undefined,
|
checkInTime: undefined,
|
||||||
stopType: null,
|
stopType: null,
|
||||||
@@ -206,6 +207,10 @@ export function planDayWindow(
|
|||||||
for (let i = 0; i < stops.length; i++) {
|
for (let i = 0; i < stops.length; i++) {
|
||||||
const { stop, day } = stops[i]!;
|
const { stop, day } = stops[i]!;
|
||||||
const pin = parseClock(stop.time);
|
const pin = parseClock(stop.time);
|
||||||
|
const leave = parseClock(stop.leaveAt);
|
||||||
|
// When the drive into this stop set out, on this day's clock: for one a night broke
|
||||||
|
// up, the morning it went on.
|
||||||
|
let setOut: number | null = null;
|
||||||
if (previous && number < day.dayNumber && !targets.has(number)) {
|
if (previous && number < day.dayNumber && !targets.has(number)) {
|
||||||
if (window.endMode !== 'stop') clock = Math.max(clock, window.end);
|
if (window.endMode !== 'stop') clock = Math.max(clock, window.end);
|
||||||
previous = night(previous);
|
previous = night(previous);
|
||||||
@@ -215,7 +220,10 @@ export function planDayWindow(
|
|||||||
}
|
}
|
||||||
} else if (!previous) {
|
} else if (!previous) {
|
||||||
number = day.dayNumber;
|
number = day.dayNumber;
|
||||||
clock = pin ?? window.start;
|
// A time set to leave the first stop starts the day early the way a pinned arrival
|
||||||
|
// does. Otherwise a ferry at seven on a day whose travel hours begin at nine was
|
||||||
|
// reported missed by two hours, when nothing but the preference made it late.
|
||||||
|
clock = pin ?? (leave === null ? window.start : Math.min(window.start, leave));
|
||||||
}
|
}
|
||||||
if (issue) return failed(issue);
|
if (issue) return failed(issue);
|
||||||
if (pin !== null && day.dayNumber !== number) return failed('conflict');
|
if (pin !== null && day.dayNumber !== number) return failed('conflict');
|
||||||
@@ -223,8 +231,15 @@ export function planDayWindow(
|
|||||||
if (previous) {
|
if (previous) {
|
||||||
const leg = legs[i - 1]!;
|
const leg = legs[i - 1]!;
|
||||||
const minutes = leg.seg.duration / 60;
|
const minutes = leg.seg.duration / 60;
|
||||||
if (pin !== null && chainAt(number).stops.every((s) => s.automaticNight)) {
|
// The same for the first stop after a night: the morning starts early enough to get
|
||||||
clock = Math.min(clock, pin - minutes);
|
// there by its pinned time, or to leave it by the time it is set to be left at. A
|
||||||
|
// pin that cannot be made is a conflict. A leave time is only ever missed, so one
|
||||||
|
// out of reach even from midnight moves nothing and says so once the stop is reached.
|
||||||
|
let early: number | null = null;
|
||||||
|
if (pin !== null) early = pin - minutes;
|
||||||
|
else if (leave !== null && leave - minutes >= 0) early = leave - minutes;
|
||||||
|
if (early !== null && early < clock && chainAt(number).stops.every((s) => s.automaticNight)) {
|
||||||
|
clock = early;
|
||||||
if (clock < 0) return failed('conflict');
|
if (clock < 0) return failed('conflict');
|
||||||
const startEntry = chainAt(number).schedule.entries[0]!;
|
const startEntry = chainAt(number).schedule.entries[0]!;
|
||||||
startEntry.arrival = formatClock(Math.round(clock));
|
startEntry.arrival = formatClock(Math.round(clock));
|
||||||
@@ -265,6 +280,7 @@ export function planDayWindow(
|
|||||||
if (issue) return failed(issue);
|
if (issue) return failed(issue);
|
||||||
}
|
}
|
||||||
const remainder = portion(leg, fraction, 1, unit);
|
const remainder = portion(leg, fraction, 1, unit);
|
||||||
|
setOut = clock;
|
||||||
clock += minutes * (1 - fraction);
|
clock += minutes * (1 - fraction);
|
||||||
putLeg(previous, stop, remainder);
|
putLeg(previous, stop, remainder);
|
||||||
}
|
}
|
||||||
@@ -281,8 +297,17 @@ export function planDayWindow(
|
|||||||
// driving hours. The night between two of them is not a pause in the stay: standing
|
// driving hours. The night between two of them is not a pause in the stay: standing
|
||||||
// somewhere for twenty-four hours takes twenty-four hours, and counting only the
|
// somewhere for twenty-four hours takes twenty-four hours, and counting only the
|
||||||
// hours the window is open stretched a single night over three days.
|
// hours the window is open stretched a single night over three days.
|
||||||
|
//
|
||||||
|
// A leave time decides the stay on its own: the traveller is here until then, however
|
||||||
|
// long the stop usually takes. Reached after it, the drive leaves at once and the
|
||||||
|
// stop says by how much it missed.
|
||||||
const overnight = Math.max(0, 1440 - window.end + window.start);
|
const overnight = Math.max(0, 1440 - window.end + window.start);
|
||||||
let dwell = Math.max(0, stop.dwellMinutes ?? 0);
|
const left = leave === null ? null : leaveAfter(clock, leave, setOut);
|
||||||
|
if (left && left.missedBy !== null) {
|
||||||
|
const chain = chainAt(number);
|
||||||
|
chain.schedule.warnings.push({ index: chain.stops.length - 1, code: 'missedLeave', minutes: left.missedBy });
|
||||||
|
}
|
||||||
|
let dwell = left ? left.departure - clock : Math.max(0, stop.dwellMinutes ?? 0);
|
||||||
while (dwell > 0) {
|
while (dwell > 0) {
|
||||||
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
|
const remaining = targets.has(number) ? dwell : Math.max(0, window.end - clock);
|
||||||
const spend = Math.min(dwell, remaining);
|
const spend = Math.min(dwell, remaining);
|
||||||
|
|||||||
@@ -277,6 +277,86 @@ describe('spillChains', () => {
|
|||||||
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
|
expect(chains[1]!.spills[0]!).toMatchObject({ at: 0, count: 2, fromDayNumber: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-NIGHTSPILL-018: a stop left at a set time holds the drive until then', () => {
|
||||||
|
const plan = [
|
||||||
|
day(1, 1, [
|
||||||
|
stop({ assignmentId: 1, name: 'Hamburg', time: '09:00', dwellMinutes: 0 }),
|
||||||
|
stop({ assignmentId: 2, name: 'Lueneburg', dwellMinutes: 30, leaveAt: '14:00' }),
|
||||||
|
stop({ assignmentId: 3, name: 'Celle' }),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chains = spillChains(plan, [], everyLeg(60));
|
||||||
|
|
||||||
|
expect(chains[0]!.schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
|
||||||
|
expect(chains[0]!.schedule.entries[2]!.arrival).toBe('15:00');
|
||||||
|
expect(chains[0]!.schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NIGHTSPILL-019: a missed leave time keeps its minutes when the stop moves', () => {
|
||||||
|
// Hamburg is left at 22:30 and the road takes three hours, so Neuruppin is reached at
|
||||||
|
// half past one: an hour after the half past midnight it was meant to be left at.
|
||||||
|
const plan = [
|
||||||
|
day(1, 1, [
|
||||||
|
stop({ assignmentId: 1, name: 'Hamburg', time: '21:00', dwellMinutes: 90 }),
|
||||||
|
stop({ assignmentId: 2, name: 'Neuruppin', leaveAt: '00:30' }),
|
||||||
|
]),
|
||||||
|
day(2, 2, [stop({ assignmentId: 3, name: 'Wittenberg', time: '10:00' })]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chains = spillChains(plan, [], everyLeg(180));
|
||||||
|
|
||||||
|
expect(chains[1]!.stops[0]!.name).toBe('Neuruppin');
|
||||||
|
expect(chains[1]!.schedule.warnings).toContainEqual({ index: 0, code: 'missedLeave', minutes: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NIGHTSPILL-020: a leave time the night drive went past is missed, not held until the next evening', () => {
|
||||||
|
// Hamburg at eight, four and a half hours to Neuruppin: in at half past midnight, an
|
||||||
|
// hour after the half past eleven it was meant to be left at. Read on the day of the
|
||||||
|
// arrival it held the drive there for twenty-three hours and Potsdam fell a day behind.
|
||||||
|
const plan = [
|
||||||
|
day(1, 1, [
|
||||||
|
stop({ assignmentId: 1, name: 'Hamburg', time: '20:00', dwellMinutes: 0 }),
|
||||||
|
stop({ assignmentId: 2, name: 'Neuruppin', leaveAt: '23:30' }),
|
||||||
|
stop({ assignmentId: 3, name: 'Potsdam' }),
|
||||||
|
]),
|
||||||
|
day(2, 2, [stop({ assignmentId: 4, name: 'Wittenberg', time: '10:00' })]),
|
||||||
|
day(3, 3, [stop({ assignmentId: 5, name: 'Leipzig', time: '10:00' })]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chains = spillChains(plan, [], everyLeg(270));
|
||||||
|
const dayTwo = chains.find((c) => c.dayNumber === 2)!;
|
||||||
|
|
||||||
|
expect(dayTwo.stops.map((s) => s.name)).toEqual(['Neuruppin', 'Potsdam', 'Wittenberg']);
|
||||||
|
expect(dayTwo.schedule.entries[0]!).toMatchObject({ arrival: '00:30', departure: '00:30' });
|
||||||
|
expect(dayTwo.schedule.entries[1]!.arrival).toBe('05:00');
|
||||||
|
expect(dayTwo.schedule.warnings).toContainEqual({ index: 0, code: 'missedLeave', minutes: 60 });
|
||||||
|
expect(chains.find((c) => c.dayNumber === 3)!.stops.map((s) => s.name)).toEqual(['Leipzig']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NIGHTSPILL-021: a first stop left in the morning after a long stay keeps its day together', () => {
|
||||||
|
// A hotel stayed at for twelve hours and left at eight. Worked back from its stay it
|
||||||
|
// was reached the evening before, and the rest of the day moved onto the next card.
|
||||||
|
const plan = [
|
||||||
|
day(1, 1, [
|
||||||
|
stop({ assignmentId: 1, name: 'Hotel', dwellMinutes: 720, leaveAt: '08:00' }),
|
||||||
|
stop({ assignmentId: 2, name: 'Celle' }),
|
||||||
|
stop({ assignmentId: 3, name: 'Hannover' }),
|
||||||
|
]),
|
||||||
|
day(2, 2, [stop({ assignmentId: 4, name: 'Hildesheim' }), stop({ assignmentId: 5, name: 'Goslar' })]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const chains = spillChains(plan, [], everyLeg(60));
|
||||||
|
|
||||||
|
expect(chains[0]!.stops.map((s) => s.name)).toEqual(['Hotel', 'Celle', 'Hannover']);
|
||||||
|
expect(chains[0]!.schedule.entries.map((e) => e.arrival)).toEqual(['00:00', '09:00', '10:00']);
|
||||||
|
expect(chains[0]!.schedule.warnings).toEqual([]);
|
||||||
|
// Nothing reaches into day two either: the day ended at ten in the morning.
|
||||||
|
expect(chains[1]!.stops.map((s) => s.name)).toEqual(['Hildesheim', 'Goslar']);
|
||||||
|
expect(chains[1]!.schedule.entries.map((e) => e.arrival)).toEqual([null, null]);
|
||||||
|
expect(chains[1]!.spills).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
describe('a stay that runs past midnight carries the clock into the next day', () => {
|
describe('a stay that runs past midnight carries the clock into the next day', () => {
|
||||||
// Standing somewhere for twenty-four hours is not over when the date changes. The
|
// Standing somewhere for twenty-four hours is not over when the date changes. The
|
||||||
// day after used to begin at nothing, so a trip whose second day had no pinned time
|
// day after used to begin at nothing, so a trip whose second day had no pinned time
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types';
|
import type { QuietDay, RoadtripDay, RoadtripStop } from './planning-types';
|
||||||
import type { RouteSegment } from './planning-types';
|
import type { RouteSegment } from './planning-types';
|
||||||
import { computeSchedule, parseClock, type Schedule, hasChosenArrival } from './roadtripModel';
|
import {
|
||||||
|
computeSchedule,
|
||||||
|
parseClock,
|
||||||
|
scheduleStopOf,
|
||||||
|
type Schedule,
|
||||||
|
type ScheduleWarning,
|
||||||
|
hasChosenArrival,
|
||||||
|
} from './roadtripModel';
|
||||||
|
|
||||||
export interface SpillMark {
|
export interface SpillMark {
|
||||||
automatic?: boolean;
|
automatic?: boolean;
|
||||||
@@ -44,8 +51,8 @@ interface Placed {
|
|||||||
|
|
||||||
dayOffset: number;
|
dayOffset: number;
|
||||||
|
|
||||||
codes: ('late' | 'overnight' | 'leg' | 'range')[];
|
/** The findings the day's own schedule filed at this stop, minutes and all. */
|
||||||
lateMinutes: number | null;
|
marks: ScheduleWarning[];
|
||||||
|
|
||||||
fromDayNumber: number | null;
|
fromDayNumber: number | null;
|
||||||
departedAt: string | null;
|
departedAt: string | null;
|
||||||
@@ -93,11 +100,7 @@ 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 routed = d.stops.slice(0, -1).map((s, i) => legFor(s, d.stops[i + 1]!));
|
||||||
const legs = routed.map((l) => l?.seg);
|
const legs = routed.map((l) => l?.seg);
|
||||||
const schedule = computeSchedule(
|
const schedule = computeSchedule(
|
||||||
d.stops.map((s) => ({
|
d.stops.map(scheduleStopOf),
|
||||||
anchor: s.time ?? null,
|
|
||||||
earliest: s.checkInTime ?? null,
|
|
||||||
dwellMinutes: s.dwellMinutes,
|
|
||||||
})),
|
|
||||||
legs.map((l) => l?.duration),
|
legs.map((l) => l?.duration),
|
||||||
{ notBefore },
|
{ notBefore },
|
||||||
);
|
);
|
||||||
@@ -145,8 +148,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
|||||||
|
|
||||||
dayOffset: moved ? 0 : offset,
|
dayOffset: moved ? 0 : offset,
|
||||||
|
|
||||||
codes: marks.map((w) => w.code).filter((c) => !(moved && c === 'overnight')),
|
marks: marks.filter((w) => !(moved && w.code === 'overnight')),
|
||||||
lateMinutes: marks.find((w) => w.code === 'late')?.minutes ?? null,
|
|
||||||
fromDayNumber: moved ? d.dayNumber : null,
|
fromDayNumber: moved ? d.dayNumber : null,
|
||||||
departedAt: moved ? (schedule.entries[i - 1]?.departure ?? null) : null,
|
departedAt: moved ? (schedule.entries[i - 1]?.departure ?? null) : null,
|
||||||
leg: moved ? legs[i - 1] : undefined,
|
leg: moved ? legs[i - 1] : undefined,
|
||||||
@@ -192,11 +194,7 @@ export function spillChains(plan: PlanDay[], quietDays: QuietDay[], legFor: LegL
|
|||||||
anchored: hasChosenArrival(p.stop),
|
anchored: hasChosenArrival(p.stop),
|
||||||
dayOffset: p.dayOffset,
|
dayOffset: p.dayOffset,
|
||||||
})),
|
})),
|
||||||
warnings: placed.flatMap((p, i) =>
|
warnings: placed.flatMap((p, i) => p.marks.map((w) => ({ ...w, index: i }))),
|
||||||
p.codes.map((code) =>
|
|
||||||
code === 'late' ? { index: i, code, minutes: p.lateMinutes ?? 0 } : { index: i, code },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
spills,
|
spills,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export interface RoadtripStop {
|
|||||||
|
|
||||||
time: string | null;
|
time: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the traveller leaves this stop: the visit's own end time, or the place's.
|
||||||
|
* The stay then runs until it rather than for `dwellMinutes`.
|
||||||
|
*/
|
||||||
|
leaveAt?: string | null;
|
||||||
|
|
||||||
dwellMinutes: number | null;
|
dwellMinutes: number | null;
|
||||||
checkInTime?: string | null;
|
checkInTime?: string | null;
|
||||||
endDay?: boolean;
|
endDay?: boolean;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
splitIntoRuns,
|
splitIntoRuns,
|
||||||
sumLegSeconds,
|
sumLegSeconds,
|
||||||
refuelStopTypeFor,
|
refuelStopTypeFor,
|
||||||
|
leaveAfter,
|
||||||
|
scheduleStopOf,
|
||||||
} from './roadtripModel';
|
} from './roadtripModel';
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
@@ -808,20 +810,274 @@ describe('checkout without daily travel times', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses checkout as departure even without an arrival or daily start', () => {
|
it('uses a set departure even without an arrival or daily start', () => {
|
||||||
|
// Nothing before the stop says when it is reached, so its own stay does: an hour
|
||||||
|
// before eight. The stop ahead of it is worked back from there like from a pin.
|
||||||
const schedule = computeSchedule(
|
const schedule = computeSchedule(
|
||||||
[
|
[
|
||||||
{ anchor: null, dwellMinutes: 60 },
|
{ anchor: null, dwellMinutes: 60 },
|
||||||
{ anchor: null, dwellMinutes: 60, departureAt: 1440 + 480 },
|
{ anchor: null, dwellMinutes: 60, departureAt: 480 },
|
||||||
{ anchor: null, dwellMinutes: 0 },
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
],
|
],
|
||||||
[3600, 7200],
|
[3600, 7200],
|
||||||
);
|
);
|
||||||
expect(schedule.entries[0]!.arrival).toBeNull();
|
expect(schedule.entries[0]!).toMatchObject({ arrival: '05:00', departure: '06:00', anchored: false });
|
||||||
expect(schedule.entries[1]!.arrival).toBeNull();
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '07:00', departure: '08:00', anchored: false });
|
||||||
expect(schedule.entries[1]!.departure).toBe('08:00');
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '10:00', dayOffset: 0 });
|
||||||
expect(schedule.entries[2]!.arrival).toBe('10:00');
|
expect(schedule.warnings).toEqual([]);
|
||||||
expect(schedule.entries[2]!.dayOffset).toBe(1);
|
});
|
||||||
|
|
||||||
|
describe('a time set to leave a stop', () => {
|
||||||
|
const hour = 3600;
|
||||||
|
|
||||||
|
it('holds the stop until then, whatever its stay', () => {
|
||||||
|
// Arrive at ten, stay half an hour by default, but leave at two: the next stop is
|
||||||
|
// reached at three, not at half past eleven.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '09:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00' });
|
||||||
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '15:00' });
|
||||||
|
expect(schedule.warnings).toEqual([]);
|
||||||
|
expect(schedule.endsAt).toBe(15 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is kept when the stop is reached later, as long as it is still ahead', () => {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '12:30', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 60, departureAt: 14 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
// Half an hour there, because that is what is left until two, not the hour it takes.
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '13:30', departure: '14:00' });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('15:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the drive gets there after it, and leaves at once', () => {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '13:30', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '14:30', departure: '14:30' });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('15:30');
|
||||||
|
expect(schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins both ends of the stay when the arrival is set too', () => {
|
||||||
|
// Start and End on one visit: arrive at ten, leave at two. The stay is the four hours
|
||||||
|
// between them, not the hour the place carries.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '09:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: '10:00', dwellMinutes: 60, departureAt: 14 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('15:00');
|
||||||
|
expect(schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works backwards from it like from a pinned arrival', () => {
|
||||||
|
// Nothing is pinned before the stop. Leaving at two with half an hour there means
|
||||||
|
// arriving at half past one, and the stop before it is left at half past twelve.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 30, departureAt: 14 * 60 },
|
||||||
|
{ anchor: '15:00', dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[0]!).toMatchObject({ arrival: '12:30', departure: '12:30', anchored: false });
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '13:30', departure: '14:00', anchored: false });
|
||||||
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '15:00', anchored: true });
|
||||||
|
expect(schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the next pinned stop late when it is left too late to reach it', () => {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '09:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 0, departureAt: 14 * 60 },
|
||||||
|
{ anchor: '14:30', dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.warnings).toEqual([{ index: 2, code: 'late', minutes: 30 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts on the day the stop is reached', () => {
|
||||||
|
// Reached after midnight on a drive through the night: the ten o'clock it is left at
|
||||||
|
// is that morning's, not the morning before.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '20:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 0, departureAt: 10 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[6 * hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '02:00', departure: '10:00', dayOffset: 1 });
|
||||||
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '11:00', dayOffset: 1 });
|
||||||
|
expect(schedule.warnings).toEqual([{ index: 1, code: 'overnight' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is missed when the drive passes it on the way, midnight or not', () => {
|
||||||
|
// Left at eight in the evening, four and a half hours on the road: the half past
|
||||||
|
// eleven the stop was to be left at went by on the way. It is not the half past
|
||||||
|
// eleven of the next evening, which held the drive there for twenty-three hours.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '20:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 0, departureAt: 23 * 60 + 30 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[4.5 * hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '00:30', departure: '00:30', dayOffset: 1 });
|
||||||
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '01:30', dayOffset: 1 });
|
||||||
|
expect(schedule.warnings).toEqual([
|
||||||
|
{ index: 1, code: 'missedLeave', minutes: 60 },
|
||||||
|
{ index: 1, code: 'overnight' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits all day for a time late in the evening', () => {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '08:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 60, departureAt: 21 * 60 + 30 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: '09:00', departure: '21:30', dayOffset: 0 });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('22:30');
|
||||||
|
expect(schedule.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a time just after midnight as that night, with or without a start', () => {
|
||||||
|
// Reached at nine in the evening, left at one: four hours, not a departure twenty
|
||||||
|
// hours in the past. The same visit with a start at ten (which only MCP can write,
|
||||||
|
// the form refuses an end before its start) runs past midnight the same way.
|
||||||
|
for (const anchor of [null, '22:00']) {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '20:00', dwellMinutes: 0 },
|
||||||
|
{ anchor, dwellMinutes: 30, departureAt: 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!.departure).toBe('01:00');
|
||||||
|
expect(schedule.entries[2]!).toMatchObject({ arrival: '02:00', dayOffset: 1 });
|
||||||
|
expect(schedule.warnings).toEqual([{ index: 2, code: 'overnight' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the first stop of the day start at midnight at the earliest', () => {
|
||||||
|
// A hotel stayed at for twelve hours and left at eight. Worked back from its stay it
|
||||||
|
// was reached at eight the evening before, which pushed the whole day onto the next.
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: null, dwellMinutes: 720, departureAt: 8 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[hour, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries.map((e) => [e.arrival, e.departure, e.dayOffset])).toEqual([
|
||||||
|
['00:00', '08:00', 0],
|
||||||
|
['09:00', '09:00', 0],
|
||||||
|
['10:00', '10:00', 0],
|
||||||
|
]);
|
||||||
|
expect(schedule.warnings).toEqual([]);
|
||||||
|
expect(schedule.endsAt).toBe(10 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a departure it cannot place yet, after a leg that never routed', () => {
|
||||||
|
const schedule = computeSchedule(
|
||||||
|
[
|
||||||
|
{ anchor: '09:00', dwellMinutes: 0 },
|
||||||
|
{ anchor: null, dwellMinutes: 0, departureAt: 14 * 60 },
|
||||||
|
{ anchor: null, dwellMinutes: 0 },
|
||||||
|
],
|
||||||
|
[undefined, hour],
|
||||||
|
);
|
||||||
|
expect(schedule.entries[1]!).toMatchObject({ arrival: null, departure: '14:00' });
|
||||||
|
expect(schedule.entries[2]!.arrival).toBe('15:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaveAfter', () => {
|
||||||
|
it('waits for a time still ahead of the arrival', () => {
|
||||||
|
expect(leaveAfter(600, 840)).toEqual({ departure: 840, missedBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves on arrival for a time already past, and says by how much', () => {
|
||||||
|
expect(leaveAfter(870, 840)).toEqual({ departure: 870, missedBy: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgives the minute a rounded drive can add', () => {
|
||||||
|
expect(leaveAfter(841, 840)).toEqual({ departure: 841, missedBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the time on the day of the arrival', () => {
|
||||||
|
expect(leaveAfter(1440 + 120, 600)).toEqual({ departure: 1440 + 600, missedBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a time the drive went past on the way as missed, across midnight too', () => {
|
||||||
|
// Set out at 20:00, in at 00:30, meant to leave at 23:30.
|
||||||
|
expect(leaveAfter(1470, 1410, 1200)).toEqual({ departure: 1470, missedBy: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for a time more than half a day ahead rather than calling the last one missed', () => {
|
||||||
|
expect(leaveAfter(540, 1290, 480)).toEqual({ departure: 1290, missedBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a time after midnight as that night when the one this morning is long gone', () => {
|
||||||
|
expect(leaveAfter(1320, 60)).toEqual({ departure: 1500, missedBy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still calls a time missed that went by before the drive set out, the same afternoon', () => {
|
||||||
|
// The stop before was left at half past two for a stop meant to be left at two. That
|
||||||
|
// is late, not a reason to stand there until two tomorrow.
|
||||||
|
expect(leaveAfter(930, 840, 870)).toEqual({ departure: 930, missedBy: 90 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduleStopOf', () => {
|
||||||
|
it('turns a leave time into a departure', () => {
|
||||||
|
expect(scheduleStopOf({ time: '10:00', checkInTime: null, dwellMinutes: 60, leaveAt: '14:00' })).toEqual({
|
||||||
|
anchor: '10:00',
|
||||||
|
earliest: null,
|
||||||
|
dwellMinutes: 60,
|
||||||
|
departureAt: 840,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the departure out when there is no usable leave time', () => {
|
||||||
|
expect(scheduleStopOf({ time: null, dwellMinutes: 30 })).toEqual({
|
||||||
|
anchor: null,
|
||||||
|
earliest: null,
|
||||||
|
dwellMinutes: 30,
|
||||||
|
});
|
||||||
|
expect(scheduleStopOf({ time: null, dwellMinutes: 30, leaveAt: 'soon' })).not.toHaveProperty('departureAt');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RouteAvoidClass } from './planning-types';
|
import type { RoadtripStop, RouteAvoidClass } from './planning-types';
|
||||||
|
|
||||||
export const SERVICE_STOP_TYPES = [
|
export const SERVICE_STOP_TYPES = [
|
||||||
'fuel',
|
'fuel',
|
||||||
@@ -73,6 +73,14 @@ export function formatClock(minutes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleStop {
|
export interface ScheduleStop {
|
||||||
|
/**
|
||||||
|
* Minute this stop is left at. A time of day is put on a day against the arrival (see
|
||||||
|
* `leaveAfter`); past 1440 it is counted from the start of the day the stop is reached
|
||||||
|
* on and reaches into the days after.
|
||||||
|
*
|
||||||
|
* The stay then lasts until then instead of for `dwellMinutes`, and the chain carries
|
||||||
|
* on from it.
|
||||||
|
*/
|
||||||
departureAt?: number;
|
departureAt?: number;
|
||||||
/** A time somebody fixed this stop to. The chain restarts from it, and arriving
|
/** A time somebody fixed this stop to. The chain restarts from it, and arriving
|
||||||
* after it is being late. */
|
* after it is being late. */
|
||||||
@@ -90,7 +98,8 @@ export interface ScheduleStop {
|
|||||||
|
|
||||||
export interface ScheduleWarning {
|
export interface ScheduleWarning {
|
||||||
index: number;
|
index: number;
|
||||||
code: 'late' | 'overnight' | 'leg' | 'range';
|
/** `missedLeave` is a stop reached after the time it was set to be left at. */
|
||||||
|
code: 'late' | 'overnight' | 'leg' | 'range' | 'missedLeave';
|
||||||
|
|
||||||
minutes?: number;
|
minutes?: number;
|
||||||
|
|
||||||
@@ -172,6 +181,63 @@ function resolveArrival(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a stop that is left at a set time is left, reached at `arrival`.
|
||||||
|
*
|
||||||
|
* Getting there after the time is not a reason to wait for the same time tomorrow: the
|
||||||
|
* traveller is late for a departure they chose themselves, so the drive leaves on arrival
|
||||||
|
* and `missedBy` says how late. The question is only which passing of the clock counts.
|
||||||
|
*
|
||||||
|
* The one the clock last showed before the arrival counts when the drive went past it on
|
||||||
|
* the road, which is what `setOut` (when the drive towards the stop left the one before,
|
||||||
|
* counted like the arrival) is for: a 23:30 reached at 00:30 is missed, midnight or not.
|
||||||
|
* It also counts when it went by earlier the same day and less than half a day ago, like
|
||||||
|
* a stop meant to be left at two and reached at half past.
|
||||||
|
*
|
||||||
|
* Anything else is the next one. Ten in the morning after a night drive is that
|
||||||
|
* morning's, one at night after an evening arrival is that night's, and a stop reached
|
||||||
|
* at nine with an End of half past nine in the evening is stood at all day.
|
||||||
|
*/
|
||||||
|
export function leaveAfter(
|
||||||
|
arrival: number,
|
||||||
|
departureAt: number,
|
||||||
|
setOut: number | null = null,
|
||||||
|
): { departure: number; missedBy: number | null } {
|
||||||
|
// A departure a day or more out is on its day already, counted from the start of the one
|
||||||
|
// the stop is reached on. Only a time of day has to be put on one.
|
||||||
|
if (departureAt >= DAY_MINUTES) {
|
||||||
|
return { departure: departureAt + Math.floor(arrival / DAY_MINUTES) * DAY_MINUTES, missedBy: null };
|
||||||
|
}
|
||||||
|
const before = departureAt + Math.floor((arrival - departureAt) / DAY_MINUTES) * DAY_MINUTES;
|
||||||
|
const late = arrival - before;
|
||||||
|
// The minute a rounded drive can add is not being late.
|
||||||
|
if (late <= 1) return { departure: arrival, missedBy: null };
|
||||||
|
const drivenPast = setOut !== null && before >= setOut;
|
||||||
|
const earlierToday =
|
||||||
|
Math.floor(before / DAY_MINUTES) === Math.floor(arrival / DAY_MINUTES) && late <= DAY_MINUTES / 2;
|
||||||
|
if (drivenPast || earlierToday) return { departure: arrival, missedBy: Math.round(late) };
|
||||||
|
return { departure: before + DAY_MINUTES, missedBy: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The part of a road trip stop the schedule reads.
|
||||||
|
*
|
||||||
|
* A visit's end time is when the drive leaves it. It is the traveller's own statement
|
||||||
|
* about this visit, unlike the check-out that used to feed `departureAt` (the LATEST a
|
||||||
|
* room has to be handed back, which is why #2357 took it out of the drive).
|
||||||
|
*/
|
||||||
|
export function scheduleStopOf(
|
||||||
|
stop: Pick<RoadtripStop, 'time' | 'checkInTime' | 'dwellMinutes' | 'leaveAt'>,
|
||||||
|
): ScheduleStop {
|
||||||
|
const leave = parseClock(stop.leaveAt);
|
||||||
|
return {
|
||||||
|
anchor: stop.time ?? null,
|
||||||
|
earliest: stop.checkInTime ?? null,
|
||||||
|
dwellMinutes: stop.dwellMinutes,
|
||||||
|
...(leave === null ? {} : { departureAt: leave }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param opts.notBefore Minute of this day the first stop cannot be reached before,
|
* @param opts.notBefore Minute of this day the first stop cannot be reached before,
|
||||||
* because the day before is still running into it: a stop stood at past midnight ends
|
* because the day before is still running into it: a stop stood at past midnight ends
|
||||||
@@ -187,9 +253,15 @@ export function computeSchedule(
|
|||||||
const warnings: ScheduleWarning[] = [];
|
const warnings: ScheduleWarning[] = [];
|
||||||
|
|
||||||
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
const arrivals: (number | null)[] = new Array(stops.length).fill(null);
|
||||||
|
// Counted the same way as the arrivals. Set with every arrival, and on its own for a
|
||||||
|
// stop whose leave time is known before anything says when it is reached.
|
||||||
|
const departures: (number | null)[] = new Array(stops.length).fill(null);
|
||||||
const anchored: boolean[] = new Array(stops.length).fill(false);
|
const anchored: boolean[] = new Array(stops.length).fill(false);
|
||||||
|
|
||||||
let cursor: number | null = opts.notBefore ?? null;
|
let cursor: number | null = opts.notBefore ?? null;
|
||||||
|
// When the drive the cursor stands for left the stop before, which is what tells a leave
|
||||||
|
// time passed on the road from one still ahead.
|
||||||
|
let setOut: number | null = null;
|
||||||
let dayOffset = 0;
|
let dayOffset = 0;
|
||||||
|
|
||||||
for (let i = 0; i < stops.length; i++) {
|
for (let i = 0; i < stops.length; i++) {
|
||||||
@@ -215,7 +287,14 @@ export function computeSchedule(
|
|||||||
|
|
||||||
if (arrival === null) {
|
if (arrival === null) {
|
||||||
const leg = legSeconds[i];
|
const leg = legSeconds[i];
|
||||||
if (stop.departureAt !== undefined && leg !== undefined) cursor = stop.departureAt + Math.round(leg / 60);
|
if (stop.departureAt !== undefined) {
|
||||||
|
const departure = stop.departureAt + dayOffset * DAY_MINUTES;
|
||||||
|
departures[i]! = departure;
|
||||||
|
if (leg !== undefined) {
|
||||||
|
cursor = departure + Math.round(leg / 60);
|
||||||
|
setOut = departure;
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,21 +306,39 @@ export function computeSchedule(
|
|||||||
// drive actually had to wait for it.
|
// drive actually had to wait for it.
|
||||||
anchored[i]! = anchor !== null || held;
|
anchored[i]! = anchor !== null || held;
|
||||||
|
|
||||||
|
let departure = arrival + (stop.dwellMinutes ?? 0);
|
||||||
|
if (stop.departureAt !== undefined) {
|
||||||
|
const left = leaveAfter(arrival, stop.departureAt, setOut);
|
||||||
|
departure = left.departure;
|
||||||
|
if (left.missedBy !== null) warnings.push({ index: i, code: 'missedLeave', minutes: left.missedBy });
|
||||||
|
}
|
||||||
|
departures[i]! = departure;
|
||||||
|
|
||||||
const leg = legSeconds[i];
|
const leg = legSeconds[i];
|
||||||
cursor =
|
cursor = leg === undefined ? null : departure + Math.round(leg / 60);
|
||||||
leg === undefined
|
setOut = leg === undefined ? null : departure;
|
||||||
? null
|
|
||||||
: (stop.departureAt === undefined ? arrival + (stop.dwellMinutes ?? 0) : Math.max(arrival, stop.departureAt)) +
|
|
||||||
Math.round(leg / 60);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstKnown = arrivals.findIndex((a) => a !== null);
|
// Before the first time anything fixes, the chain runs backwards: every stop is reached
|
||||||
for (let i = firstKnown - 1; i >= 0; i--) {
|
// early enough to stay its length and still make the next one. A stop left at a set time
|
||||||
if (stops[i]!.departureAt !== undefined) break;
|
// is reached its stay before that time, the way a pinned arrival is left a stay after.
|
||||||
|
// The first stop of the day is not reached before the day began, though: a hotel left at
|
||||||
|
// eight after a twelve hour stay is where the day starts, not a stop reached the evening
|
||||||
|
// before, and reading it that way moved everything after it onto tomorrow's card.
|
||||||
|
const firstKnown = stops.findIndex((_, i) => arrivals[i] !== null || departures[i] !== null);
|
||||||
|
for (let i = firstKnown; i >= 0; i--) {
|
||||||
|
if (arrivals[i] !== null) continue;
|
||||||
|
const dwell = stops[i]!.dwellMinutes ?? 0;
|
||||||
|
const fixed = departures[i]!;
|
||||||
|
if (fixed !== null) {
|
||||||
|
arrivals[i]! = i === 0 ? Math.max(0, fixed - dwell) : fixed - dwell;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const leg = legSeconds[i];
|
const leg = legSeconds[i];
|
||||||
const next = arrivals[i + 1]!;
|
const next = arrivals[i + 1] ?? null;
|
||||||
if (leg === undefined || next === null) break;
|
if (leg === undefined || next === null) break;
|
||||||
arrivals[i]! = next - Math.round(leg / 60) - (stops[i]!.dwellMinutes ?? 0);
|
arrivals[i]! = next - Math.round(leg / 60) - dwell;
|
||||||
|
departures[i]! = next - Math.round(leg / 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null);
|
const earliest = arrivals.reduce<number | null>((m, a) => (a === null ? m : m === null || a < m ? a : m), null);
|
||||||
@@ -251,10 +348,11 @@ export function computeSchedule(
|
|||||||
let lastOffset = 0;
|
let lastOffset = 0;
|
||||||
for (let i = 0; i < stops.length; i++) {
|
for (let i = 0; i < stops.length; i++) {
|
||||||
const raw = arrivals[i]!;
|
const raw = arrivals[i]!;
|
||||||
|
const left = departures[i]!;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
entries.push({
|
entries.push({
|
||||||
arrival: null,
|
arrival: null,
|
||||||
departure: stops[i]!.departureAt === undefined ? null : formatClock(stops[i]!.departureAt!),
|
departure: left === null ? null : formatClock(left + shift),
|
||||||
anchored: false,
|
anchored: false,
|
||||||
dayOffset: 0,
|
dayOffset: 0,
|
||||||
});
|
});
|
||||||
@@ -267,11 +365,7 @@ export function computeSchedule(
|
|||||||
lastOffset = offset;
|
lastOffset = offset;
|
||||||
entries.push({
|
entries.push({
|
||||||
arrival: formatClock(arrival),
|
arrival: formatClock(arrival),
|
||||||
departure: formatClock(
|
departure: formatClock(left! + shift),
|
||||||
stops[i]!.departureAt === undefined
|
|
||||||
? arrival + (stops[i]!.dwellMinutes ?? 0)
|
|
||||||
: Math.max(arrival, stops[i]!.departureAt! + shift),
|
|
||||||
),
|
|
||||||
anchored: anchored[i]!,
|
anchored: anchored[i]!,
|
||||||
dayOffset: offset,
|
dayOffset: offset,
|
||||||
});
|
});
|
||||||
@@ -284,11 +378,7 @@ export function computeSchedule(
|
|||||||
for (let i = stops.length - 1; i >= 0; i--) {
|
for (let i = stops.length - 1; i >= 0; i--) {
|
||||||
const raw = arrivals[i];
|
const raw = arrivals[i];
|
||||||
if (raw === null || raw === undefined) continue;
|
if (raw === null || raw === undefined) continue;
|
||||||
const arrival = raw + shift;
|
endsAt = departures[i]! + shift;
|
||||||
endsAt =
|
|
||||||
stops[i]!.departureAt === undefined
|
|
||||||
? arrival + (stops[i]!.dwellMinutes ?? 0)
|
|
||||||
: Math.max(arrival, stops[i]!.departureAt! + shift);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user