mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +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();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// Build an assignment whose place has end_time before place_time
|
||||
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.
|
||||
*/
|
||||
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}
|
||||
dayAssignments={dayAssignments}
|
||||
hasTimeError={hasTimeError}
|
||||
endIsLeave={!!props.roadtripActive}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -1270,10 +1273,13 @@ interface TimeSectionProps {
|
||||
assignmentId: number | null
|
||||
dayAssignments: Assignment[]
|
||||
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
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) {
|
||||
|
||||
const collisions = useMemo(() => {
|
||||
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}
|
||||
onChange={v => handleChange('end_time', v)}
|
||||
/>
|
||||
{endIsLeave && <p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.endIsLeave')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{hasTimeError && (
|
||||
|
||||
@@ -322,7 +322,10 @@ describe('RoadtripSidebar', () => {
|
||||
const add = within(chain).getByText('+')
|
||||
|
||||
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', () => {
|
||||
@@ -342,6 +345,53 @@ describe('RoadtripSidebar', () => {
|
||||
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', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
|
||||
@@ -31,6 +31,8 @@ import { FS } from './typeScale'
|
||||
import type { RouteSegment } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AutomaticDayStop from './AutomaticDayStop'
|
||||
import type { StayDraft } from './RoadtripStayModal'
|
||||
import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading'
|
||||
|
||||
interface RoadtripSidebarProps {
|
||||
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 —
|
||||
* 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.
|
||||
*
|
||||
@@ -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 text = minutes ? formatDurationShort(minutes * 60) : null
|
||||
if (!text && !onEdit) return null
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
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 label = (
|
||||
@@ -301,7 +310,8 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
|
||||
}`}
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text ?? '+'}
|
||||
{text ?? (until ? null : '+')}
|
||||
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -323,7 +333,7 @@ function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () =>
|
||||
e.stopPropagation()
|
||||
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`}
|
||||
>
|
||||
{label}{value}
|
||||
@@ -749,9 +759,10 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
|
||||
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
|
||||
stop: RoadtripStop
|
||||
entry: ScheduleEntry | undefined
|
||||
/** How late the drive reaches a time pinned on this pause, the same finding a numbered
|
||||
* stop shows. A fuel or charging halt can carry a pinned time like anything else. */
|
||||
late: ScheduleWarning | undefined
|
||||
/** How late the drive reaches a time pinned on this pause, or the time it was set to
|
||||
* be left at: the same findings a numbered stop shows. A fuel or charging halt can
|
||||
* 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
|
||||
* as far as the tank is concerned, so it carries them the same way a numbered one does. */
|
||||
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>
|
||||
<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) ? (
|
||||
<FillBadge
|
||||
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
||||
@@ -844,7 +855,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
|
||||
) : null}
|
||||
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
||||
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
<LateBadge late={late} />
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{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. */
|
||||
number: number
|
||||
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. */
|
||||
driveFindings?: ScheduleWarning[]
|
||||
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
|
||||
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">
|
||||
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
|
||||
<StayBadge stay={readStay(stop, entry)} onEdit={onEditStay} />
|
||||
{refuelsRange(stop.stopType, vehicleKind) ? (
|
||||
<FillBadge
|
||||
percent={effectiveFill(stop.fillPercent, fillPercent)}
|
||||
@@ -1099,7 +1111,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
|
||||
) : null}
|
||||
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
|
||||
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
|
||||
<LateBadge late={late} />
|
||||
{late.map(w => <LateBadge key={w.code} late={w} />)}
|
||||
</span>
|
||||
</span>
|
||||
{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
|
||||
* 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()
|
||||
if (!late) return null
|
||||
const label = t('roadtrip.warn.late', { minutes: late.minutes ?? 0 })
|
||||
const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 })
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
@@ -1348,8 +1359,9 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
const service = isServiceStopType(stop.stopType)
|
||||
if (!service) counted += 1
|
||||
// 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.
|
||||
const marks = day.schedule.warnings.filter(w => w.index === i)
|
||||
// overnight crossing swallow the "you arrive late" flag without a trace, and a stop
|
||||
// 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 ownIndex = stop.ownerIndex
|
||||
return (
|
||||
@@ -1388,11 +1400,11 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
<ServiceStop
|
||||
stop={stop}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={marks.find(w => w.code === 'late')}
|
||||
late={lateness}
|
||||
driveFindings={findingsFor(i)}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
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}
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
@@ -1401,7 +1413,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
stop={stop}
|
||||
number={counted}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={marks.find(w => w.code === 'late')}
|
||||
late={lateness}
|
||||
driveFindings={findingsFor(i)}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
continues={i < last}
|
||||
@@ -1409,7 +1421,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||
onMove={onReorderStop ? delta => onReorderStop(ownDay, stop.assignmentId, ownIndex + delta) : undefined}
|
||||
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}
|
||||
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
|
||||
/>
|
||||
|
||||
@@ -2,10 +2,14 @@ import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
|
||||
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 { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||
import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import { useLeaveMode, type LeaveMode } from './useLeaveMode'
|
||||
|
||||
/**
|
||||
* The lengths a stop usually takes, so the common answer is one tap.
|
||||
@@ -33,6 +34,15 @@ export interface StayDraft {
|
||||
minutes: number | null
|
||||
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
||||
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 {
|
||||
@@ -75,6 +85,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [minutes, setMinutes] = useState(0)
|
||||
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
|
||||
// from whatever the last one was left on.
|
||||
@@ -97,6 +108,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
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) => {
|
||||
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
|
||||
decides it — so the arrow shows the one end this dialog does move. */}
|
||||
{times ? (
|
||||
<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}
|
||||
{times ? <ArriveLeave arrive={times.arrive} leave={times.leave} carry={times.leaveCarry} /> : null}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESETS.map(value => (
|
||||
@@ -237,3 +225,106 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
|
||||
</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.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 EndDayControl, { type EndDayControlProps } from './EndDayControl'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
|
||||
export interface RoadtripStayControl {
|
||||
minutes: number | null
|
||||
/** The time the visit is set to be left at, which is what makes its stay. */
|
||||
until?: string | null
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export default function VisitControls({ endDay, stay }: { endDay?: EndDayControlProps; stay?: RoadtripStayControl }) {
|
||||
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 (
|
||||
<div className={`grid gap-2 ${endDay && stay ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{endDay && <EndDayControl {...endDay} />}
|
||||
{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">
|
||||
<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">
|
||||
{stay.minutes === null ? '+' : formatDurationShort(stay.minutes * 60)}{stay.onEdit && <Pencil size={12} className="text-content-faint" aria-hidden />}
|
||||
{' '}<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 ? (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>
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
|
||||
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 {
|
||||
return {
|
||||
@@ -277,6 +277,23 @@ describe('pickWarning', () => {
|
||||
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
|
||||
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.
|
||||
@@ -32,12 +33,13 @@ export interface StopRow {
|
||||
entry: ScheduleEntry | undefined
|
||||
/** The clock this row shows on the right: when the traveller gets there. */
|
||||
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
|
||||
/** True when a person pinned that time, which is what the pin in front of it means. */
|
||||
pinned: boolean
|
||||
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
|
||||
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
|
||||
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
|
||||
* 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
|
||||
* remark you can act on tonight.
|
||||
* arrival after it, and so does missing the time a stop was meant to be left at; running
|
||||
* 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> = {
|
||||
late: 0,
|
||||
range: 1,
|
||||
leg: 2,
|
||||
overnight: 3,
|
||||
missedLeave: 1,
|
||||
range: 2,
|
||||
leg: 3,
|
||||
overnight: 4,
|
||||
}
|
||||
|
||||
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
|
||||
@@ -92,7 +95,7 @@ function stopRow(
|
||||
departure: entry?.departure ?? null,
|
||||
pinned: !!entry?.anchored,
|
||||
warning: pickWarning(mine),
|
||||
dwellMinutes: stop.dwellMinutes,
|
||||
dwellMinutes: readStay(stop, entry).minutes,
|
||||
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
|
||||
stopType?: string | null
|
||||
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 {
|
||||
@@ -45,6 +49,7 @@ function assignment(spec: StopSpec, order: number): Assignment {
|
||||
place_id: spec.id * 10,
|
||||
order_index: order,
|
||||
assignment_time: spec.time ?? null,
|
||||
assignment_end_time: spec.end ?? null,
|
||||
leg_transport_mode: spec.legMode ?? null,
|
||||
incoming_leg_transport_mode: spec.incoming ?? null,
|
||||
place: {
|
||||
@@ -53,6 +58,9 @@ function assignment(spec: StopSpec, order: number): Assignment {
|
||||
lat: spec.noCoords ? null : spec.at[0],
|
||||
lng: spec.noCoords ? null : spec.at[1],
|
||||
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,
|
||||
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', () => ({
|
||||
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,
|
||||
lng: p.lng,
|
||||
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,
|
||||
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
||||
endDay: a.end_day === true,
|
||||
|
||||
Reference in New Issue
Block a user