feat(tours): give a tour its start and end in the day plan

A tour on a day had no place in the day's timeline: its time was not tied
to the slot it was planned in, so several hikes on one day could not be
lined up. A tour now sits there the way a place does, on the times of its
day entry, one model for every tour type. With a start and a known time
(the planned total, else the estimate of an import) its end follows as the
start plus that time; it is shown fainter, marked with an approximate sign
where it comes from an estimate, and says in its tooltip how it was worked
out. An end typed by hand wins. A day that holds one stage of a multi-day
tour gets no end from the whole route.

When the end runs past the start of the day's next timed item, the row
says so in one quiet line, as a late connection between two city walks
does; nothing is moved. The existing time fields of the day entry, on the
desktop form and the phone sheet, say what the end works out to while none
is typed. City walks keep their arrivals per station. The logic lives in
one module both day plans run on.
This commit is contained in:
Maurice
2026-10-20 20:44:25 +02:00
parent c38eb7ebf0
commit 9765cf3fee
46 changed files with 865 additions and 12 deletions
@@ -124,6 +124,7 @@ function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
transitLegFor: vi.fn(() => null),
planTransitLeg: vi.fn(),
journeyLate: vi.fn(() => null),
tourOverrun: vi.fn(() => null),
...over,
} as unknown as MPlanTimelineController
}
@@ -391,6 +392,14 @@ describe('MPlanTimeline', () => {
renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false })
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
})
it('TIME-02-020: a tour row says when its end runs into the next timed item, read from the shared timeline logic', () => {
const validRow: PlanRow = { ...row, invalidTour: false }
const tourOverrun = vi.fn(() => ({ name: 'Lunch', time: '12:00', end: '13:00' }))
renderTimeline({ rows: [validRow], merged: [item], upNext: null, tourOverrun }, { toursEnabled: true, tours: [] })
expect(tourOverrun).toHaveBeenCalledWith(item)
expect(screen.getByTestId('tour-day-overrun')).toBeInTheDocument()
})
})
describe('per-segment travel mode', () => {
@@ -7,7 +7,7 @@ import { seedStore } from '../../../helpers/store'
import { buildSettings } from '../../../helpers/factories'
import { useSettingsStore } from '../../../../src/store/settingsStore'
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-015
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-017
const planner = buildPlanner()
@@ -171,4 +171,40 @@ describe('PlTimeFields', () => {
setup()
expect(screen.queryByText('roadtrip.stop.endIsLeave')).not.toBeInTheDocument()
})
// TIME-02: an imported hike of 12 km, 300 m up and 500 m down, estimated at 4 h.
const hike = {
place_id: 1, name: 'Museum ridge', tour_type: 'hike', distance: 12, elevation_gain: 300, elevation_loss: 500,
duration: null, planned_duration_minutes: null, break_additional_minutes: null, difficulty: null, wanderer_ref: null,
match_confidence: 1, max_hiking_difficulty: null, planned: true, caution: false,
}
function renderTour(startTime: string, endTime: string) {
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
return render(
<PlTimeFields
planner={buildPlanner({ tours: [hike] } as never)}
startTime={startTime}
endTime={endTime}
onChange={vi.fn()}
assignmentId={1}
dayAssignments={[SELF]}
hasTimeError={false}
/>,
)
}
it('FE-MOB-PLTIME-016: a tour without a typed end says the end its start and its time work out to', () => {
renderTour('09:00', '')
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('13:00')
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('≈ 4 h')
})
it('FE-MOB-PLTIME-017: before a start it says one will set the end, and a typed end needs no hint', () => {
const view = renderTour('', '')
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('≈ 4 h')
view.unmount()
renderTour('09:00', '15:00')
expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument()
})
})
@@ -115,4 +115,19 @@ describe('useMPlanTimeline, public transit between two city walks (CW-08)', () =
if (!item) throw new Error('no journey on the day')
expect(result.current.journeyLate(item)).toEqual({ walk: 'Harbour', time: '13:00' })
})
it('FE-MOB-PLTL-HOOK-005: says a walk whose end runs past the next walk start, the desktop rule (TIME-02)', () => {
const late = buildPlanner({
...walkPlanner(),
tours: [{ ...walk(oldTown), planned_duration_minutes: 240 }, walk(harbour)],
} as never)
const { result } = renderHook(() => useMPlanTimeline(late))
const [first, second] = result.current.merged
expect(result.current.tourOverrun(first)).toEqual({ name: 'Harbour', time: '13:00', end: '14:00' })
expect(result.current.tourOverrun(second)).toBeNull()
// In time, nothing is said.
const { result: inTime } = renderHook(() => useMPlanTimeline(walkPlanner()))
expect(inTime.current.tourOverrun(inTime.current.merged[0])).toBeNull()
})
})