feat(tours): show a multi-day tour's stages in the list and put each on a day

A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
This commit is contained in:
Maurice
2026-10-20 13:21:10 +02:00
parent 6ec06c90f4
commit 8fa75b3c4e
100 changed files with 2787 additions and 127 deletions
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
import { projectDayItinerary } from './dayTourProjection'
import { projectDayItinerary, tourStageStopsByPlace } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
@@ -437,6 +437,54 @@ describe('Tour day itinerary segments', () => {
})
})
// MDT-02: a day holding one stage of a multi-day tour routes to that stage and draws its stretch.
describe('A stage of a multi-day tour on a day', () => {
const line: [number, number][] = [[48, 11], [48.01, 11], [48.02, 11], [48.03, 11], [48.04, 11]]
const stops = [
{ key: 'stop-start', lat: 48, lng: 11 },
{ key: 'stop-hut', lat: 48.02, lng: 11 },
{ key: 'stop-end', lat: 48.04, lng: 11 },
]
const hutToHut = (order: number, tour_stage_key: string | null) => {
const assignment = at(48, 11, order)
return { ...assignment, tour_place_id: assignment.place.id, tour_route_geometry: JSON.stringify(line), tour_stage_key }
}
it('FE-MAP-DRP-017: draws and routes to the stretch of the stage, the stops telling where it runs', () => {
const second = hutToHut(1, 'stop-hut')
const stageStops = new Map([[second.place.id, stops]])
expect(projectDayItinerary([second], true, [], stageStops)[0]).toMatchObject({
kind: 'tour', valid: true, start: { lat: 48.02, lng: 11 }, end: { lat: 48.04, lng: 11 }, geometry: line.slice(2),
})
const first = hutToHut(1, 'stop-start')
expect(projectDayItinerary([first], true, [], new Map([[first.place.id, stops]]))[0]).toMatchObject({
start: { lat: 48, lng: 11 }, end: { lat: 48.02, lng: 11 },
})
const runs = buildDayRouteRuns(1, {
...inputs({ assignments: { '1': [at(47.99, 11, 0), second, at(48.05, 11, 2)] } }),
toursEnabled: true, tourStageStops: stageStops,
})
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[47.99, 11], [48.02, 11]], [[48.04, 11], [48.05, 11]]])
})
it('FE-MAP-DRP-018: keeps the whole tour where the stops do not tell the stage', () => {
const whole = hutToHut(1, null)
const unknown = hutToHut(1, 'stop-gone')
const last = hutToHut(1, 'stop-end')
for (const assignment of [whole, unknown, last]) {
expect(projectDayItinerary([assignment], true, [], new Map([[assignment.place.id, stops]]))[0]).toMatchObject({ geometry: line })
}
const noStops = hutToHut(1, 'stop-hut')
expect(projectDayItinerary([noStops], true)[0]).toMatchObject({ geometry: line })
})
it('FE-MAP-DRP-019: reads the stage stops of the multi-day tours off the tour list, none for a day tour', () => {
const stages = tourStageStopsByPlace([{ place_id: 4, stage_stops: stops }, { place_id: 5, stage_stops: null }, { place_id: 6 }])
expect([...stages.keys()]).toEqual([4])
expect(stages.get(4)).toBe(stops)
})
})
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
// plain stop drew a second road from the track start to the next stop, alongside the
// track itself. Any assigned place with a track now leaves that stretch to the track.
+4 -2
View File
@@ -3,7 +3,7 @@ import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarri
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '@trek/shared/roadtrip'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import { projectDayItinerary } from './dayTourProjection'
import { projectDayItinerary, type TourStageStops } from './dayTourProjection'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
@@ -41,6 +41,8 @@ export interface DayRouteInputs {
optimizeFromAccommodation: boolean | undefined
toursEnabled?: boolean
places?: Place[]
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to it. */
tourStageStops?: TourStageStops
}
/**
@@ -83,7 +85,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places, input.tourStageStops).map(item => [item.assignment.id, item]))
type Entry =
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
+36 -1
View File
@@ -1,5 +1,32 @@
import type { TourListItem, TourStageStop } from '@trek/shared'
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import type { Assignment, Place } from '../../types'
import { vertexIndicesOf } from '../Tours/stages/tourStages'
/** MDT-02: the stops each multi-day tour's stages run between, by the tour's place id. */
export type TourStageStops = ReadonlyMap<number, readonly TourStageStop[]>
/** The stage stops of the trip's multi-day tours, from the tour list; a day tour has none. */
export function tourStageStopsByPlace(tours: readonly Pick<TourListItem, 'place_id' | 'stage_stops'>[]): TourStageStops {
return new Map(tours.flatMap(tour => (tour.stage_stops ? [[tour.place_id, tour.stage_stops] as const] : [])))
}
/**
* MDT-02: the stretch of a tour's line one stage runs, from the stop it starts
* at to the stop the next one starts at; null where the stops do not tell it
* (a stage the tour no longer has, a line that has not reached them yet).
*/
function stageStretch(
line: [number, number][],
stops: readonly TourStageStop[] | undefined,
stageKey: string | null | undefined,
): [number, number][] | null {
if (!stageKey || !stops || line.length < 2) return null
const index = stops.findIndex(stop => stop.key === stageKey)
if (index < 0 || index >= stops.length - 1) return null
const at = vertexIndicesOf(stops, line)
return at[index + 1] > at[index] ? line.slice(at[index], at[index + 1] + 1) : null
}
export type DayItineraryItem =
| { kind: 'stop'; assignment: Assignment }
@@ -14,10 +41,17 @@ export type DayItineraryItem =
valid: boolean
}
/**
* A day's stops with each tour projected onto its line: the day's route runs
* to where the tour starts and on from where it ends. A day holding one stage
* of a multi-day tour (MDT-02) runs to that stage's start and on from its end,
* and draws that stage's stretch, where `stageStops` tells them.
*/
export function projectDayItinerary(
assignments: Assignment[],
toursEnabled: boolean,
places: readonly Place[] = [],
stageStops?: TourStageStops,
): DayItineraryItem[] {
const placesById = new Map(places.map(place => [place.id, place]))
return assignments.map(assignment => {
@@ -37,7 +71,8 @@ export function projectDayItinerary(
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
}
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
const geometry = analysis?.routeCoordinates ?? []
const whole = analysis?.routeCoordinates ?? []
const geometry = stageStretch(whole, stageStops?.get(placeId), assignment.tour_stage_key) ?? whole
const start = geometry.length >= 2
? { lat: geometry[0][0], lng: geometry[0][1] }
: assignment.place?.lat != null && assignment.place?.lng != null
@@ -107,6 +107,7 @@ export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey):
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
input.toursEnabled ?? false,
input.places,
input.tourStageStops,
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
}))
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import type { RoadtripVia } from '@trek/shared'
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
import type { TourStageStops } from './dayTourProjection'
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
@@ -46,6 +47,8 @@ export function useTripRouteOverview(
enabled: boolean,
places: Place[] = EMPTY_PLACES,
vias?: TripRouteVias,
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one draws that stage. */
tourStageStops?: TourStageStops,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
@@ -66,9 +69,9 @@ export function useTripRouteOverview(
const viasByDay = vias?.viasByDay
const plan = useMemo(
() => (enabled
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
: []),
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay, tourStageStops],
)
// Only geometry and mode decide whether legs have to be fetched again: renaming a
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
import { formatTime } from '../../utils/formatters';
import { fs } from '../shared/DialogShell';
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
import { stageNumberOf, TourStageDayPills } from '../Tours/stages/TourStageDayPills';
import { movingTimeLabel } from '../Tours/tourFacts';
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
import { WalkProgressPill } from '../Tours/walk/WalkStations';
@@ -22,6 +23,8 @@ interface DayPlanPlaceSublineProps {
timeFormat: string;
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
assignmentId?: number;
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; absent for the whole tour. */
tourStageKey?: string | null;
}
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
@@ -71,6 +74,12 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
);
}
// The tour's own pills, or a stage's where the day holds one stage of a multi-day tour (MDT-02).
function DayRowTourPills({ tour, stageKey }: { tour: TourListItem; stageKey?: string | null }) {
if (stageKey && stageNumberOf(tour, stageKey) !== null) return <TourStageDayPills tour={tour} stageKey={stageKey} />;
return <TourDayRowPills tour={tour} />;
}
// Description line, one line like every stop's.
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
return (
@@ -100,6 +109,7 @@ export function DayPlanPlaceSubline({
isTourPlace,
timeFormat,
assignmentId,
tourStageKey,
}: DayPlanPlaceSublineProps) {
const { locale } = useTranslation();
const dayRowTour = tourTimes?.tour;
@@ -115,7 +125,7 @@ export function DayPlanPlaceSubline({
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</TimePill>
)}
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour ? <DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
</div>
@@ -69,7 +69,7 @@ import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnec
import { resolveLegMode } from './legMode'
import { useLegModeActions } from './useLegModeActions'
import { dayExportStops, fillAroundLocked } from './dayRoute'
import { projectDayItinerary } from '../Map/dayTourProjection'
import { projectDayItinerary, type TourStageStops } from '../Map/dayTourProjection'
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
@@ -94,6 +94,8 @@ export interface DayPlanSidebarProps {
places: Place[]
tourPlaceIds?: ReadonlySet<number>
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to that stage. */
tourStageStops?: TourStageStops
categories: Category[]
assignments: AssignmentsMap
selectedDayId: number | null
@@ -621,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
const planDay = (dayId: number) => {
const merged = mergedItemsMap[dayId] || []
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item]))
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places, props.tourStageStops).map(item => [item.assignment.id, item]))
// Each run point carries the ORIGIN assignment's per-segment travel mode
// (#1281): the leg from this point to the next is routed with `mode` (null =
// inherit the day default). Transport endpoints carry no mode → day default.
@@ -840,7 +842,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
// effect on the string is equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay])
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay, props.tourStageStops])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -2030,7 +2032,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
)}
</div>
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} tourStageKey={assignment.tour_stage_key} />
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
+10 -2
View File
@@ -3,7 +3,7 @@ import { useId, type ReactNode } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
import { TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import { FoldButton, TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
import type { Day } from '../../types'
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
import { TourDayRowPills } from './TourDayRowFacts'
@@ -28,6 +28,8 @@ interface TourListRowProps {
trackColor?: string
/** PR1-07: the person's switch for this tour's line on the trip map. */
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
/** MDT-02: a multi-day tour unfolds its stages below the row; `controls` is the id of what unfolds. */
fold?: { expanded: boolean; onToggle: () => void; label: string; controls: string }
}
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
@@ -118,7 +120,7 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
export default function TourListRow({ tour,
days = NO_DAYS,
selected = false, disabled = false,
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
draggableToDay = false, onSelect, action, children, trackColor, mapToggle, fold }: TourListRowProps) {
const nameId = useId()
const tourDays = useTourDays(tour.place_id, days)
const draggable = draggableToDay && !disabled
@@ -152,6 +154,12 @@ export default function TourListRow({ tour,
)}
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
{fold && (
// Always in sight, unlike the hover actions: the stages are part of what the row is.
<span role="presentation" className="flex-none" onClick={event => event.stopPropagation()}>
<FoldButton expanded={fold.expanded} onToggle={fold.onToggle} controls={fold.controls} label={fold.label} />
</span>
)}
</div>
{children}
</li>
@@ -12,6 +12,7 @@ import type { Day } from '../../../types'
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'
import { tourRepo } from '../../../repo/tourRepo'
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
vi.mock('../../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
@@ -127,6 +128,17 @@ beforeEach(() => {
})
describe('TourPlannerToursRail', () => {
it('MDT-02: a multi-day tour unfolds its stages in the list, a day tour has nothing to unfold', async () => {
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour, waypoints: [] })
const hutToHut = { ...tour, place_id: 43, name: 'Hut to hut', stage_count: 2 }
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour, hutToHut]} {...railProps} />)
expect(screen.getAllByRole('button', { name: 'Show stages' })).toHaveLength(1)
expect(screen.queryByTestId('tour-stage-overview')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Show stages' }))
expect(screen.getByTestId('tour-stage-overview')).toHaveAttribute('id', 'tour-stages-43')
expect(screen.getByRole('button', { name: 'Hide stages' })).toHaveAttribute('aria-expanded', 'true')
})
it('RS-02: keeps calculated Walking time visible in the right Tour metrics rail', () => {
render(<TourPlannerToursRail planner={planner({
mode: { type: 'new-draft' }, durationSeconds: 4800,
@@ -32,9 +32,13 @@ import type { SkiClassification } from '../ski/useSkiClassification'
import { TourTypeFields } from './TourTypeFields'
import TourWaypointList from './TourWaypointList'
import { TourStageLossNotice } from '../stages/TourStageLossNotice'
import { TourStageDayNotice } from '../stages/TourStageDayNotice'
import { TourStageModeField } from '../stages/TourStageModeField'
import { TourStagesSection } from '../stages/TourStagesSection'
import { stageBoundariesM } from '../stages/tourStages'
import { TourStageOverview } from '../stages/TourStageOverview'
import { useTourStageRail } from '../stages/useTourStageRail'
import { useTripStore } from '../../../store/tripStore'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -473,6 +477,7 @@ export function TourPlannerRail({ planner,
<UnreachableNotice planner={planner} />
<TourStageLossNotice planner={planner} />
<TourStageDayNotice planner={planner} />
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} tourPlaceIds={tourPlaceIds} />
@@ -731,12 +736,28 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMe
]
}
/** MDT-02: the id of a tour's unfolded stages, which its fold button controls. */
const stageListId = (tour: TourListItem) => `tour-stages-${tour.place_id}`
/** The fold of a multi-day tour's row in the list; none for a day tour. */
function stagesFold(tour: TourListItem, rail: ReturnType<typeof useTourStageRail>, t: (key: string) => string) {
if ((tour.stage_count ?? 0) < 2) return undefined
const expanded = rail.isUnfolded(tour.place_id)
return {
expanded,
onToggle: () => rail.toggle(tour.place_id),
label: t(expanded ? 'tours.stages.fold' : 'tours.stages.unfold'),
controls: stageListId(tour),
}
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const toast = useToast()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const tripId = useTripStore(state => state.trip?.id)
const [pendingTour, setPendingTour] = useState<TourListItem | null>(null)
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
// STA-01: one filter for the list and the summary above it, the same as the plan's tour list.
@@ -776,6 +797,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
}
selectTour(tour)
}
// MDT-02: a multi-day tour unfolds its stages here, and a stage opens in the planner.
const stageRail = useTourStageRail(planner, requestOpenTour)
const tourTimeFields = plannerTimeFields(planner, t)
@@ -835,6 +858,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
disabled={isSaving}
selected={(planner.mode.type === 'edit-saved' || planner.mode.type === 'view-gpx') && planner.mode.placeId === tour.place_id}
onSelect={requestOpenTour}
fold={stagesFold(tour, stageRail, t)}
action={((canAssign && days.length > 0) || (canEdit && onDeleteTour)) ? (
<>
{canAssign && days.length > 0 && (
@@ -860,7 +884,12 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
)}
</>
) : undefined}
/>
>
{stageRail.isUnfolded(tour.place_id) && (tour.stage_count ?? 0) > 1 && (
<TourStageOverview id={stageListId(tour)} tripId={tripId} tour={tour} days={days} canAssign={canAssign}
onOpenStage={isSaving ? undefined : stageRail.openStage} />
)}
</TourListRow>
))}
</TourListCard>
)}
@@ -0,0 +1,95 @@
import { CalendarCheck2, CalendarDays, CalendarPlus, CalendarX2, ChevronDown, Loader2 } from 'lucide-react';
import type { MouseEvent } from 'react';
import { useTranslation } from '../../../i18n';
import type { Day } from '../../../types';
import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { dayMenuLabel } from '../tourDays';
import type { TourStageDays } from './useTourStageDays';
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
interface StageDayPickerProps {
stageKey: string;
stageNumber: number;
days: Day[];
stageDays: TourStageDays;
}
/** The days a stage can go on, the one it is on checked, and a way back to none. */
function dayItems({ stageKey, days, stageDays }: StageDayPickerProps, label: (day: Day, index: number) => string, noDay: string) {
const current = stageDays.dayOf(stageKey);
const items: MenuItems = days.map((day, index) => ({
label: label(day, index),
icon: day.id === current ? CalendarCheck2 : CalendarDays,
disabled: day.id === current || !stageDays.canTake(stageKey, day.id),
onClick: () => void stageDays.setDay(stageKey, day.id),
}));
if (current === null) return items;
return [...items, { divider: true }, { label: noDay, icon: CalendarX2, onClick: () => void stageDays.setDay(stageKey, null) }];
}
/**
* MDT-02: the day a stage of a multi-day tour is on, as a pill that opens the
* trip's days: filled with the day's number where the stage is on one, dashed
* and quiet where it is on none yet, so a stage without a day stands out in
* the list. Who may not change the days sees the pill without the menu.
*/
export function StageDayPicker(props: StageDayPickerProps) {
const { stageKey, stageNumber, days, stageDays } = props;
const { t, locale } = useTranslation();
const menu = useContextMenu();
const dayId = stageDays.dayOf(stageKey);
const index = dayId === null ? -1 : days.findIndex((day) => day.id === dayId);
const day = index >= 0 ? days[index] : null;
const pending = stageDays.isPending(stageKey);
const interactive = stageDays.canAssign && days.length > 0;
const tooltip = day ? dayMenuLabel(day, index, t, locale) : t('tours.stages.noDayHint');
const name = `${t('tours.stages.stage', { number: stageNumber })}: ${tooltip}`;
const open = (event: MouseEvent<HTMLButtonElement>) => {
if (!interactive || pending) return;
if (menu.menu) {
event.stopPropagation();
menu.close();
return;
}
menu.open(event, dayItems(props, (option, at) => dayMenuLabel(option, at, t, locale), t('tours.stages.noDay')), true);
};
const look = day
? 'bg-surface-card text-content shadow-sm ring-1 ring-inset ring-edge-faint'
: 'border border-dashed border-edge text-content-muted';
const Icon = pending ? Loader2 : day ? CalendarDays : CalendarPlus;
return (
<>
<Tooltip label={tooltip} disabled={!!menu.menu}>
<button
type="button"
data-testid="tour-stage-day"
data-stage-day={day ? day.id : 'none'}
aria-label={name}
aria-haspopup={interactive ? 'menu' : undefined}
aria-expanded={interactive ? !!menu.menu : undefined}
aria-disabled={!interactive || undefined}
onClick={open}
className={`inline-flex h-[22px] flex-none items-center gap-1 whitespace-nowrap rounded-full pe-1.5 ps-2 font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${look} ${interactive ? 'hover:text-content' : 'cursor-default'}`}
style={fs(11, 'body')}
>
<Icon
size={11}
strokeWidth={2.4}
aria-hidden="true"
className={`flex-none ${pending ? 'animate-spin' : day ? 'text-accent' : ''}`}
/>
<span className={day ? 'font-geist tabular-nums' : undefined}>
{day ? t('dayplan.dayN', { n: index + 1 }) : t('tours.stages.noDay')}
</span>
{interactive && <ChevronDown size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />}
</button>
</Tooltip>
<ContextMenu menu={interactive ? menu.menu : null} onClose={menu.close} />
</>
);
}
@@ -1,5 +1,6 @@
import type { TourListItem } from '@trek/shared';
import { ArrowDown, ArrowUp, Clock, Moon, Ruler, Sunrise, type LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { ArrowDown, ArrowRight, ArrowUp, Clock, Moon, Ruler, Sunrise, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
@@ -35,7 +36,7 @@ export function StageEndCorner() {
* wherever it is named. Its sunrise opens the day, as the moon at its last
* stop closes it.
*/
function StagePill({ number }: { number: number }) {
export function StagePill({ number }: { number: number }) {
const { t } = useTranslation();
return (
<span
@@ -49,6 +50,34 @@ function StagePill({ number }: { number: number }) {
);
}
/** A stage's number as the list numbers a stop, with the moon at its corner where a night follows. */
export function StageNumber({ number, night }: { number: number; night: boolean }) {
return (
<span
className="relative grid h-7 w-7 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={fs(11.5)}
>
{number}
{night && <StageEndCorner />}
</span>
);
}
/**
* The name a stage's stop goes by: its own, else the place found there, else
* its number as the list numbers stops (a shaping point takes none).
*/
export function stopNameOf(
points: ReadonlyArray<{ id: string; name?: string | null; shaping?: boolean }>,
placeNames: Readonly<Record<string, string>>,
id: string,
t: Translate
): string {
const index = points.findIndex((point) => point.id === id);
const stop = points.slice(0, index + 1).filter((point) => !point.shaping).length;
return points[index]?.name || placeNames[id] || t('tours.planner.waypointLabel', { n: stop });
}
/**
* What a stage measures, as the plan's white badges: its distance, climb and
* descent where the route has heights, and its moving time. A time over a day
@@ -59,15 +88,20 @@ export function StageMetricBadges({
stage,
compact = false,
align = 'start',
unmeasured,
}: {
stage: TourStage;
/** Distance and time only, for the narrow head of a stage in the list of points. */
compact?: boolean;
align?: 'start' | 'end';
/** Said instead, where the route tells nothing about the stage yet (MDT-02). */
unmeasured?: ReactNode;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const icon = (Icon: LucideIcon) => <Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />;
const known = [stage.distanceKm, stage.gain, stage.loss, stage.minutes].some((value) => value !== null);
if (!known && unmeasured) return <>{unmeasured}</>;
return (
<span className={`flex min-w-0 flex-wrap items-center gap-1 ${align === 'end' ? 'justify-end' : 'justify-start'}`}>
{stage.distanceKm !== null && (
@@ -96,6 +130,42 @@ export function StageMetricBadges({
);
}
/**
* A stage at a glance: its number (with the moon where a night follows), the
* stops it runs from and to, and what it measures. The planner's overview and
* the list's (MDT-02) show a stage alike.
*/
export function StageSummary({
stage,
night,
from,
to,
unmeasured,
}: {
stage: TourStage;
night: boolean;
from: string;
to: string;
unmeasured?: ReactNode;
}) {
return (
<>
<StageNumber number={stage.number} night={night} />
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span
className="flex w-full min-w-0 items-center gap-1 font-semibold leading-tight text-content"
style={fs(12.5, 'body')}
>
<span className="min-w-0 truncate">{from}</span>
<ArrowRight size={12} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint rtl:rotate-180" />
<span className="min-w-0 truncate">{to}</span>
</span>
<StageMetricBadges stage={stage} unmeasured={unmeasured} />
</span>
</>
);
}
/**
* The head of a stage in the list of points, where the stage begins: its pill,
* a hairline, and what it measures. The rows of its points follow; the stop
@@ -0,0 +1,31 @@
import { CalendarX2 } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useTripStore } from '../../../store/tripStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourNotice } from '../tourParts';
import { stageStartKeysOf } from './tourStages';
import { stageDaysOf } from './useTourStageDays';
type DayLossPlanner = Pick<TourPlannerController, 'mode' | 'waypoints'>;
/**
* MDT-02: before a change is saved, the stages on a day of the trip that the
* change takes away (their stage end gone, the tour a day tour again) are
* counted, since the save takes them off their days. Said while there are
* any, so nothing leaves the day plan unannounced.
*/
export function TourStageDayNotice({ planner }: { planner: DayLossPlanner }) {
const { t } = useTranslation();
const assignments = useTripStore((state) => state.assignments);
if (planner.mode.type !== 'edit-saved') return null;
const kept = new Set(stageStartKeysOf(planner.waypoints));
const lost = [...stageDaysOf(planner.mode.placeId, assignments ?? {}).keys()].filter((key) => !kept.has(key)).length;
if (lost === 0) return null;
return (
<div data-testid="tour-stage-day-loss">
<TourNotice tone="warning" role="status" icon={<CalendarX2 size={13} strokeWidth={2} />}>
{t('tours.stages.dayLost', { count: lost })}
</TourNotice>
</div>
);
}
@@ -0,0 +1,30 @@
import type { TourListItem } from '@trek/shared';
import { useTripStore } from '../../../store/tripStore';
import { StageMetricBadges, StagePill } from './StageParts';
import { useSavedTourStages } from './useSavedTourStages';
/** The number of the stage a day holds, from the stops the tour list names; null for a stage the tour no longer has. */
export function stageNumberOf(tour: Pick<TourListItem, 'stage_stops'>, stageKey: string): number | null {
const index = (tour.stage_stops ?? []).slice(0, -1).findIndex((stop) => stop.key === stageKey);
return index >= 0 ? index + 1 : null;
}
/**
* MDT-02: a day that holds one stage of a multi-day tour says which, with the
* stage's own distance and time rather than the whole tour's, as far as the
* saved tour tells them. The calendar, the journey there and the night stay
* the day plan's own.
*/
export function TourStageDayPills({ tour, stageKey }: { tour: TourListItem; stageKey: string }) {
const tripId = useTripStore((state) => state.trip?.id);
const number = stageNumberOf(tour, stageKey);
const saved = useSavedTourStages(tripId, tour, number !== null);
if (number === null) return null;
const stage = saved.stages.find((item) => item.key === stageKey);
return (
<span data-testid="trip-plan-tour-stage" className="inline-flex min-w-0 flex-none items-center gap-1.5">
<StagePill number={number} />
{stage && <StageMetricBadges stage={stage} compact />}
</span>
);
}
@@ -0,0 +1,184 @@
import type { TourListItem } from '@trek/shared';
import { ArrowDown, ArrowUp, ArrowUpRight, Clock, Loader2, Ruler, Sigma, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { Day } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { movingTimeLabel } from '../tourFacts';
import { TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { StageDayPicker } from './StageDayPicker';
import { StageSummary, stopNameOf } from './StageParts';
import type { StageSum, StageTotals } from './tourStages';
import { useSavedTourStages } from './useSavedTourStages';
import { useTourStageDays, type TourStageDays } from './useTourStageDays';
type Translate = ReturnType<typeof useTranslation>['t'];
interface TourStageOverviewProps {
id: string;
tripId: number | string | null | undefined;
tour: TourListItem;
days: Day[];
canAssign: boolean;
/** Opens the tour in the planner at this stage, by the key of the stop it starts at; absent where it cannot. */
onOpenStage?: (tour: TourListItem, stageKey: string) => void;
}
/** A sum as a badge: the total of the stages that tell it, marked as at least that where some do not. */
function TotalBadge({ icon: Icon, label, sum, format, t }: { icon: LucideIcon; label: string; sum: StageSum; format: (value: number) => string; t: Translate }) {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
const said = partial ? `${label}: ${value} (${t('tours.stages.totalPartial', { known: sum.known, of: sum.of })})` : `${label}: ${value}`;
return (
<Tooltip label={said}>
<span className="inline-flex" aria-label={said} data-stage-total-partial={partial || undefined}>
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{value}</WhiteBadge>
</span>
</Tooltip>
);
}
/**
* The whole tour as its stages add up, under them and set apart from the
* trip's own figures: each measure only as far as the stages tell it, and
* none where none does. A tour counts once here however many days hold it.
*/
export function StageTotalsRow({ tour, totals }: { tour: TourListItem; totals: StageTotals }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const height = (value: number) => formatElevation(value, unit);
// A route not planned yet adds up to nothing: no row rather than an empty one.
if (Object.values(totals).every((sum) => sum.value === null)) return null;
return (
<div data-testid="tour-stage-totals" className="mt-1 flex min-w-0 flex-wrap items-center gap-1 border-t border-edge-faint px-1.5 pb-0.5 pt-2">
<span className="me-auto inline-flex items-center gap-1 font-semibold text-content-secondary" style={fs(11.5, 'body')}>
<Sigma size={12} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
{t('tours.stages.wholeTour')}
</span>
<TotalBadge icon={Ruler} label={t('tours.detail.distance')} sum={totals.distanceKm} format={(value) => formatDistance(value, unit)} t={t} />
<TotalBadge icon={ArrowUp} label={t('tours.detail.ascent')} sum={totals.gain} format={height} t={t} />
<TotalBadge icon={ArrowDown} label={t('tours.detail.descent')} sum={totals.loss} format={height} t={t} />
<TotalBadge
icon={Clock}
label={movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}
sum={totals.minutes}
format={formatPlannedTourDuration}
t={t}
/>
</div>
);
}
/** The round button that opens the tour in the planner at one stage. */
function OpenStageButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Tooltip label={label}>
<button
type="button"
aria-label={label}
data-testid="tour-stage-open"
onClick={onClick}
className="grid h-[22px] w-[22px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-accent hover:text-accent-text hover:ring-transparent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ArrowUpRight size={12} strokeWidth={2.4} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
</Tooltip>
);
}
/** What a stage offers on its right: the day it is on, and the way into the planner. */
function StageActions({
stageKey,
stageNumber,
days,
stageDays,
onOpen,
}: {
stageKey: string;
stageNumber: number;
days: Day[];
stageDays: TourStageDays;
onOpen?: (stageKey: string) => void;
}) {
const { t } = useTranslation();
return (
<span className="flex flex-none flex-col items-end gap-1">
<StageDayPicker stageKey={stageKey} stageNumber={stageNumber} days={days} stageDays={stageDays} />
{onOpen && <OpenStageButton label={t('tours.stages.openInPlanner')} onClick={() => onOpen(stageKey)} />}
</span>
);
}
/**
* MDT-02: a multi-day tour unfolded in the list of the trip's tours: its
* stages in route order with what each measures and the day it is on, and
* what the stages add up to. A stage opens in the planner, where it is edited
* as part of the one route, and goes on a day of the trip (or none) on its
* own. Nothing here keeps a copy of a stage: the figures are read off the
* saved tour each time it changes.
*/
export function TourStageOverview({ id, tripId, tour, days, canAssign, onOpenStage }: TourStageOverviewProps) {
const { t } = useTranslation();
const saved = useSavedTourStages(tripId, tour, true);
const stageDays = useTourStageDays({ tripId, placeId: tour.place_id, canAssign });
const placeNames = useWaypointPlaceNames(saved.points);
const unmeasured = (
<Tooltip label={t('tours.stages.unmeasuredHint')}>
<span className="inline-flex">
<SoftPill>{t('tours.stages.unmeasured')}</SoftPill>
</span>
</Tooltip>
);
return (
// The row around it opens the tour on a click; what happens in here stays in here.
<div
id={id}
role="presentation"
data-testid="tour-stage-overview"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className="mt-2 cursor-default rounded-xl bg-surface-secondary p-1 ring-1 ring-inset ring-edge-faint"
>
{saved.status === 'error' && <TourNotice>{t('tours.stages.loadFailed')}</TourNotice>}
{saved.status === 'loading' && (
<p className="m-0 flex items-center gap-1.5 px-2 py-2 text-content-faint" style={fs(11.5, 'body')} role="status">
<Loader2 size={12} strokeWidth={2.2} aria-hidden="true" className="animate-spin" />
{t('common.loading')}
</p>
)}
{saved.status === 'ready' && (
<>
<ol aria-label={t('tours.stages.title')} className="m-0 flex list-none flex-col gap-px p-0">
{saved.stages.map((stage) => (
<li key={stage.number} data-testid="tour-stage-item" data-stage={stage.number} className="flex min-w-0 items-start gap-2.5 rounded-[10px] px-1.5 py-1.5">
<StageSummary
stage={stage}
night={stage.number < saved.stages.length}
from={stopNameOf(saved.points, placeNames, stage.fromId, t)}
to={stopNameOf(saved.points, placeNames, stage.toId, t)}
unmeasured={unmeasured}
/>
{stage.key && (
<StageActions
stageKey={stage.key}
stageNumber={stage.number}
days={days}
stageDays={stageDays}
onOpen={onOpenStage ? (key) => onOpenStage(tour, key) : undefined}
/>
)}
</li>
))}
</ol>
{saved.totals && <StageTotalsRow tour={tour} totals={saved.totals} />}
</>
)}
</div>
);
}
@@ -1,32 +1,31 @@
import { ArrowRight } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { focusRouteProfileAtDistance } from '../../../utils/routeGeometry';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { InfoDot, TourSection } from '../tourParts';
import { StageEndCorner, StageMetricBadges } from './StageParts';
import { StageSummary, stopNameOf } from './StageParts';
import type { TourStage } from './tourStages';
/**
* Shows a stage of the tour open in the planner: its last stop selected, the
* profile at the spot it ends once the route has measured it (MDT-02 opens a
* stage from the list this way too).
*/
export function showStage(
planner: Pick<TourPlannerController, 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'>,
stage: TourStage
) {
planner.setSelectedWaypointId(stage.toId);
const samples = planner.routeAnalysis?.distanceIndexedProfileSamples ?? [];
planner.setRouteProfileFocus(stage.toM === null ? null : focusRouteProfileAtDistance(samples, stage.toM));
}
type StagesPlanner = Pick<
TourPlannerController,
'stages' | 'waypoints' | 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'
>;
/** A stage's number as the list numbers a stop, with the moon at its corner where a night follows. */
function StageNumber({ number, night }: { number: number; night: boolean }) {
return (
<span
className="relative grid h-7 w-7 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={fs(11.5)}
>
{number}
{night && <StageEndCorner />}
</span>
);
}
/**
* MDT-01: the stages of a multi-day tour under the whole tour's facts, the
* second view of the same route: each stage from the stop it starts at to the
@@ -38,17 +37,8 @@ export function TourStagesSection({ planner }: { planner: StagesPlanner }) {
const placeNames = useWaypointPlaceNames(planner.waypoints);
const { stages } = planner.stages;
if (stages.length < 2) return null;
const nameOf = (id: string) => {
const index = planner.waypoints.findIndex((point) => point.id === id);
// Stops are numbered as the list numbers them: a shaping point takes no number.
const stop = planner.waypoints.slice(0, index + 1).filter((point) => !point.shaping).length;
return planner.waypoints[index]?.name ?? placeNames[id] ?? t('tours.planner.waypointLabel', { n: stop });
};
const show = (stage: TourStage) => {
planner.setSelectedWaypointId(stage.toId);
const samples = planner.routeAnalysis?.distanceIndexedProfileSamples ?? [];
planner.setRouteProfileFocus(stage.toM === null ? null : focusRouteProfileAtDistance(samples, stage.toM));
};
const nameOf = (id: string) => stopNameOf(planner.waypoints, placeNames, id, t);
const show = (stage: TourStage) => showStage(planner, stage);
return (
<TourSection
label={t('tours.stages.title')}
@@ -64,23 +54,12 @@ export function TourStagesSection({ planner }: { planner: StagesPlanner }) {
onClick={() => show(stage)}
className="flex w-full min-w-0 items-center gap-2.5 rounded-[10px] px-1.5 py-1.5 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<StageNumber number={stage.number} night={stage.number < stages.length} />
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span
className="flex w-full min-w-0 items-center gap-1 font-semibold leading-tight text-content"
style={fs(12.5, 'body')}
>
<span className="min-w-0 truncate">{nameOf(stage.fromId)}</span>
<ArrowRight
size={12}
strokeWidth={2.2}
aria-hidden="true"
className="flex-none text-content-faint rtl:rotate-180"
/>
<span className="min-w-0 truncate">{nameOf(stage.toId)}</span>
</span>
<StageMetricBadges stage={stage} />
</span>
<StageSummary
stage={stage}
night={stage.number < stages.length}
from={nameOf(stage.fromId)}
to={nameOf(stage.toId)}
/>
</button>
</Tooltip>
</li>
@@ -0,0 +1,55 @@
import { paddleSpeedKmhSchema, recommendedPaddleSpeedKmh, type TourListItem, type TourWaypoint } from '@trek/shared';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { openedTourFields } from '../planner/tourPlannerFacts';
import { normalizePlannerWaypoints, plannerWaypointsOf, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { legLengthsAlong, tourStagesOf, type PaddleStageInputs, type TourStage } from './tourStages';
/** MDT-02: a stage of a saved tour, with the key of the stop it starts at, by which a day holds it. */
export interface SavedTourStage extends TourStage {
key: string | null;
}
/** The saved tour's points as the planner reads them, each numbered by its place in the route. */
export function savedStagePoints(tour: TourListItem, saved: readonly TourWaypoint[]): TourPlannerWaypoint[] {
let next = 0;
const points = plannerWaypointsOf(saved, () => `saved-${next++}`);
return normalizePlannerWaypoints(points, openedTourFields(tour).tourType === 'kayak');
}
/**
* A saved paddle tour's speeds as the planner works its time out: the speed
* the paddler chose, else the boat's suggestion, and each leg as long as the
* saved line runs it. Null for any other type.
*/
function paddleInputsOf(tour: TourListItem, points: readonly TourPlannerWaypoint[], line: ReadonlyArray<readonly number[]>): PaddleStageInputs | null {
if (tour.tour_type !== 'kayak') return null;
const chosen = tour.paddle_speed_kmh ?? null;
return {
effectiveSpeedKmh: chosen ?? recommendedPaddleSpeedKmh(tour.boat_type ?? null),
portageSpeedKmh: tour.portage_speed_kmh ?? null,
speedInvalid: chosen !== null && !paddleSpeedKmhSchema.safeParse(chosen).success,
legLengths: legLengthsAlong(points, line),
};
}
/**
* MDT-02: the stages of a saved multi-day tour, read the way the planner reads
* them (MDT-01) off its saved points and its saved line, so the overview in
* the list and the planner give the same figures. Without a line every stage
* is there, unmeasured; the router's time is the tour's stored one.
*/
export function savedTourStagesOf(
tour: TourListItem,
points: readonly TourPlannerWaypoint[],
routeGeometry: string | null | undefined
): SavedTourStage[] {
const line = parseRenderableRouteGeometry(routeGeometry);
const stages = tourStagesOf({
points,
line,
tourType: openedTourFields(tour).tourType,
routerSeconds: tour.duration == null ? null : tour.duration * 60,
paddle: line ? paddleInputsOf(tour, points, line) : null,
});
return stages.map((stage) => ({ ...stage, key: points[stage.from]?.key ?? null }));
}
@@ -0,0 +1,284 @@
// FE-TOUR-STAGE-OVERVIEW-001 to FE-TOUR-STAGE-OVERVIEW-015: a multi-day tour unfolded in the list, its
// stages on days of the trip, and the day plan naming the stage a day holds (MDT-02).
import type { TourListItem, TourWaypoint } from '@trek/shared';
import { act, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { DayPlanPlaceSubline } from '../../Planner/DayPlanPlaceSubline';
import { tourRepo } from '../../../repo/tourRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap } from '../../../types';
import type { TourPlannerController } from '../planner/useTourPlanner';
import TourListRow from '../TourListRow';
import { savedStagePoints, savedTourStagesOf } from './savedTourStages';
import { tourPlannedTimes } from '../tourPresentation';
import { StageDayPicker } from './StageDayPicker';
import { stageNumberOf, TourStageDayPills } from './TourStageDayPills';
import { TourStageDayNotice } from './TourStageDayNotice';
import { StageTotalsRow, TourStageOverview } from './TourStageOverview';
import type { TourStageDays } from './useTourStageDays';
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const LINE: [number, number, number][] = [
[47, 11, 600],
[47.01, 11, 700],
[47.02, 11, 650],
[47.03, 11, 800],
];
const POINTS: TourWaypoint[] = [
{ lat: 47, lng: 11, role: 'start', sequence: 0, key: 'stop-start', name: 'Valley' },
{ lat: 47.01, lng: 11, role: 'via', sequence: 1, key: 'stop-hut', name: 'Upper hut', stage_end: true },
{ lat: 47.03, lng: 11, role: 'end', sequence: 2, key: 'stop-end', name: 'Pass' },
];
const TOUR: TourListItem = {
place_id: 81,
name: 'Hut to hut',
tour_type: 'hike',
distance: 3.3,
elevation_gain: 250,
elevation_loss: 50,
duration: 90,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: true,
caution: false,
has_waypoints: true,
revision: 2,
stage_count: 2,
stage_stops: [
{ key: 'stop-start', lat: 47, lng: 11 },
{ key: 'stop-hut', lat: 47.01, lng: 11 },
{ key: 'stop-end', lat: 47.03, lng: 11 },
],
};
const DAYS = [buildDay({ id: 3, day_number: 1, date: null }), buildDay({ id: 4, day_number: 2, date: null })];
const assignPlaceToDay = vi.fn(async () => undefined);
function seed(assignments: AssignmentsMap = { '3': [], '4': [] }, geometry: string | null = JSON.stringify(LINE)) {
seedStore(useTripStore, {
trip: { id: 9 } as never,
places: [buildPlace({ id: TOUR.place_id, route_geometry: geometry })],
assignments,
assignPlaceToDay,
});
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: TOUR, waypoints: POINTS });
});
describe('the stages of a saved tour', () => {
it('FE-TOUR-STAGE-OVERVIEW-001: reads the stages off the saved points and line, each with its starting key', () => {
const points = savedStagePoints(TOUR, POINTS);
const stages = savedTourStagesOf(TOUR, points, JSON.stringify(LINE));
expect(stages.map((stage) => [stage.number, stage.key])).toEqual([
[1, 'stop-start'],
[2, 'stop-hut'],
]);
expect(stages[0].distanceKm).toBeCloseTo(1.11, 1);
expect(stages[0].gain).toBeGreaterThan(0);
// Without a line every stage is there, unmeasured.
expect(savedTourStagesOf(TOUR, points, null).map((stage) => stage.distanceKm)).toEqual([null, null]);
});
it('FE-TOUR-STAGE-OVERVIEW-002: a paddle tour times its stages at the paddler speed and leaves heights unknown', () => {
const paddle = { ...TOUR, tour_type: 'kayak' as const, boat_type: 'canoe' as const, paddle_speed_kmh: 5 };
const stages = savedTourStagesOf(paddle, savedStagePoints(paddle, POINTS), JSON.stringify(LINE));
expect(stages[0].gain).toBeNull();
expect(stages[0].minutes).toBeGreaterThan(0);
const invalid = { ...paddle, paddle_speed_kmh: 999 };
expect(savedTourStagesOf(invalid, savedStagePoints(invalid, POINTS), JSON.stringify(LINE))[0].minutes).toBeNull();
});
it('FE-TOUR-STAGE-OVERVIEW-003: numbers the stage a day holds from the list, and none for a stage gone', () => {
expect(stageNumberOf(TOUR, 'stop-start')).toBe(1);
expect(stageNumberOf(TOUR, 'stop-hut')).toBe(2);
expect(stageNumberOf(TOUR, 'stop-end')).toBeNull();
expect(stageNumberOf({ stage_stops: null }, 'stop-hut')).toBeNull();
});
});
describe('the unfolded tour in the list', () => {
it('FE-TOUR-STAGE-OVERVIEW-004: lists the stages in route order with their stops, figures and the whole tour', async () => {
seed({ '3': [{ id: 31, place_id: 81, tour_stage_key: 'stop-hut' }], '4': [] } as unknown as AssignmentsMap);
render(<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign />);
const items = await screen.findAllByTestId('tour-stage-item');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('Valley');
expect(items[0]).toHaveTextContent('Upper hut');
expect(items[1]).toHaveTextContent('Pass');
expect(within(items[0]).getByTestId('tour-stage-day')).toHaveAttribute('data-stage-day', 'none');
expect(within(items[0]).getByTestId('tour-stage-day')).toHaveTextContent('No day');
expect(within(items[1]).getByTestId('tour-stage-day')).toHaveAttribute('data-stage-day', '3');
expect(within(items[1]).getByTestId('tour-stage-day')).toHaveTextContent('Day 1');
const totals = screen.getByTestId('tour-stage-totals');
expect(totals).toHaveTextContent('Whole tour');
expect(totals.querySelector('[data-stage-total-partial]')).toBeNull();
expect(tourRepo.detail).toHaveBeenCalledWith(9, 81, expect.any(AbortSignal));
});
it('FE-TOUR-STAGE-OVERVIEW-005: a stage the route has not measured says so, and nothing is added up', async () => {
seed(undefined, null);
render(<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign />);
const items = await screen.findAllByTestId('tour-stage-item');
expect(items[0]).toHaveTextContent('Not measured yet');
expect(screen.queryByTestId('tour-stage-totals')).toBeNull();
});
it('FE-TOUR-STAGE-OVERVIEW-014: a total some stages do not tell is marked as at least that much, and says for how many', () => {
const unknown = { value: null, known: 0, of: 2 };
render(
<StageTotalsRow
tour={TOUR}
totals={{ distanceKm: { value: 10, known: 1, of: 2 }, gain: unknown, loss: unknown, minutes: { value: 300, known: 2, of: 2 } }}
/>
);
const totals = screen.getByTestId('tour-stage-totals');
expect(totals).toHaveTextContent('≥ 10');
expect(totals.querySelectorAll('[data-stage-total-partial]')).toHaveLength(1);
expect(screen.getByLabelText(/known for 1 of 2 stages/)).toBeInTheDocument();
});
it('FE-TOUR-STAGE-OVERVIEW-006: says when the stages cannot be read, and reads them again for a new revision', async () => {
seed();
vi.mocked(tourRepo.detail).mockRejectedValueOnce(new Error('offline'));
const view = render(<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign />);
expect(await screen.findByText('The stages cannot be read right now.')).toBeInTheDocument();
view.rerender(<TourStageOverview id="stages" tripId={9} tour={{ ...TOUR, revision: 3 }} days={DAYS} canAssign />);
expect(await screen.findAllByTestId('tour-stage-item')).toHaveLength(2);
expect(tourRepo.detail).toHaveBeenCalledTimes(2);
});
it('FE-TOUR-STAGE-OVERVIEW-007: puts a stage on a day from its pill and opens it in the planner', async () => {
seed();
const onOpenStage = vi.fn();
render(<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign onOpenStage={onOpenStage} />);
const items = await screen.findAllByTestId('tour-stage-item');
await userEvent.click(within(items[1]).getByTestId('tour-stage-day'));
await userEvent.click(await screen.findByText('Day 2'));
expect(assignPlaceToDay).toHaveBeenCalledWith(9, 4, 81, null, 'stop-hut');
await userEvent.click(within(items[1]).getByTestId('tour-stage-open'));
expect(onOpenStage).toHaveBeenCalledWith(TOUR, 'stop-hut');
});
it('FE-TOUR-STAGE-OVERVIEW-008: who may not change the days sees the day without a menu and no way to open', async () => {
seed();
render(<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign={false} />);
const pill = (await screen.findAllByTestId('tour-stage-day'))[0];
expect(pill).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(pill);
expect(screen.queryByText('Day 2')).toBeNull();
expect(screen.queryByTestId('tour-stage-open')).toBeNull();
});
it('FE-TOUR-STAGE-OVERVIEW-009: a click inside the stages does not open the tour of the row', async () => {
seed();
const onSelect = vi.fn();
const onToggle = vi.fn();
render(
<ul>
<TourListRow tour={TOUR} onSelect={onSelect} fold={{ expanded: true, onToggle, label: 'Hide stages', controls: 'stages' }}>
<TourStageOverview id="stages" tripId={9} tour={TOUR} days={DAYS} canAssign />
</TourListRow>
</ul>
);
await userEvent.click(screen.getByRole('button', { name: 'Hide stages' }));
expect(onToggle).toHaveBeenCalledTimes(1);
fireEvent.click(await screen.findByTestId('tour-stage-overview'));
expect(onSelect).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Hide stages' })).toHaveAttribute('aria-expanded', 'true');
});
});
describe('a stage day picker', () => {
function daysOf(overrides: Partial<TourStageDays> = {}): TourStageDays {
return {
canAssign: true,
dayOf: vi.fn(() => 3),
isPending: vi.fn(() => false),
canTake: vi.fn((_key: string, dayId: number) => dayId !== 3),
setDay: vi.fn(async () => true),
...overrides,
};
}
it('FE-TOUR-STAGE-OVERVIEW-010: offers the other days, checks the one it is on and takes it off with no day', async () => {
const stageDays = daysOf();
render(<StageDayPicker stageKey="stop-hut" stageNumber={2} days={DAYS} stageDays={stageDays} />);
const pill = screen.getByTestId('tour-stage-day');
expect(pill).toHaveAccessibleName('Stage 2: Day 1');
await userEvent.click(pill);
expect(screen.getByRole('button', { name: 'Day 1' })).toBeDisabled();
await userEvent.click(screen.getByText('No day'));
expect(stageDays.setDay).toHaveBeenCalledWith('stop-hut', null);
});
it('FE-TOUR-STAGE-OVERVIEW-011: a stage on its way spins and opens nothing', async () => {
const stageDays = daysOf({ isPending: vi.fn(() => true), dayOf: vi.fn(() => null) });
render(<StageDayPicker stageKey="stop-hut" stageNumber={2} days={DAYS} stageDays={stageDays} />);
await userEvent.click(screen.getByTestId('tour-stage-day'));
expect(screen.queryByText('Day 2')).toBeNull();
expect(screen.getByTestId('tour-stage-day').querySelector('.animate-spin')).not.toBeNull();
});
});
describe('the stage a day holds', () => {
it('FE-TOUR-STAGE-OVERVIEW-012: the day plan names the stage with its own figures, and nothing for a stage gone', async () => {
seed();
const view = render(<TourStageDayPills tour={TOUR} stageKey="stop-hut" />);
const pills = screen.getByTestId('trip-plan-tour-stage');
expect(pills).toHaveTextContent('Stage 2');
await waitFor(() => expect(pills).toHaveTextContent('km'));
view.rerender(<TourStageDayPills tour={TOUR} stageKey="stop-gone" />);
expect(screen.queryByTestId('trip-plan-tour-stage')).toBeNull();
});
it('FE-TOUR-STAGE-OVERVIEW-013: the planner says before a save which stages on a day the change takes away', () => {
seed({ '3': [{ id: 31, place_id: 81, tour_stage_key: 'stop-hut' }] } as unknown as AssignmentsMap);
const planner = (waypoints: Array<Record<string, unknown>>, mode: unknown = { type: 'edit-saved', placeId: 81 }) =>
({ mode, waypoints }) as unknown as TourPlannerController;
const kept = [
{ id: 'a', key: 'stop-start' },
{ id: 'b', key: 'stop-hut', stageEnd: true },
{ id: 'c', key: 'stop-end' },
];
const view = render(<TourStageDayNotice planner={planner(kept)} />);
expect(screen.queryByTestId('tour-stage-day-loss')).toBeNull();
act(() => {
view.rerender(<TourStageDayNotice planner={planner(kept.map(({ stageEnd: _end, ...point }) => point))} />);
});
expect(screen.getByTestId('tour-stage-day-loss')).toHaveTextContent(
'A stage on a day no longer exists; saving takes it off its day.'
);
view.rerender(<TourStageDayNotice planner={planner(kept, { type: 'new-draft' })} />);
expect(screen.queryByTestId('tour-stage-day-loss')).toBeNull();
});
it('FE-TOUR-STAGE-OVERVIEW-015: a day plan row shows the stage it holds, and the whole tour without one', () => {
seed();
const place = buildPlace({ id: TOUR.place_id, name: 'Hut to hut' });
const times = { ...tourPlannedTimes(TOUR), tour: TOUR };
const row = (tourStageKey?: string | null) => (
<DayPlanPlaceSubline place={place} cat={undefined} tourTimes={times} isTourPlace timeFormat="24h" tourStageKey={tourStageKey} />
);
const view = render(row('stop-start'));
expect(screen.getByTestId('trip-plan-tour-stage')).toHaveTextContent('Stage 1');
view.rerender(row(null));
expect(screen.queryByTestId('trip-plan-tour-stage')).toBeNull();
view.rerender(row('stop-gone'));
expect(screen.queryByTestId('trip-plan-tour-stage')).toBeNull();
});
});
@@ -1,14 +1,16 @@
// FE-TOUR-STAGE-MODEL-001 to FE-TOUR-STAGE-MODEL-010: the stages of a multi-day tour, cut from its one route (MDT-01).
// FE-TOUR-STAGE-MODEL-001 to FE-TOUR-STAGE-MODEL-012: the stages of a multi-day tour, cut from its one route (MDT-01, MDT-02).
import { MAX_TOUR_STAGES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { hikingTimeMinutes } from '../hikingTime';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import {
canEndStage,
legLengthsAlong,
lostStageEnds,
stageBoundariesM,
stageEndCount,
stageSpansOf,
stageTotalsOf,
tourStagesOf,
vertexIndicesOf,
withoutStageEnds,
@@ -154,4 +156,24 @@ describe('tourStages', () => {
expect(together[0].distanceKm).toBe(0);
expect(together[0].gain).toBe(0);
});
it('FE-TOUR-STAGE-MODEL-011: measures each leg along the line, and leaves a leg of one vertex to the estimate (MDT-02)', () => {
const legs = legLengthsAlong([FIVE[0], FIVE[2], FIVE[4]], LINE);
expect(legs).toHaveLength(2);
expect(legs[0]).toBeCloseTo(2224, -1);
expect(legs[1]).toBeCloseTo(2224, -1);
expect(legLengthsAlong([FIVE[0], FIVE[0]], LINE)).toEqual([null]);
});
it('FE-TOUR-STAGE-MODEL-012: adds the stages up only as far as they tell, with how many do (MDT-02)', () => {
const stages = tourStagesOf(input(withEnds('b', 'd')));
const totals = stageTotalsOf(stages);
expect(totals.distanceKm.known).toBe(3);
expect(totals.distanceKm.value).toBeCloseTo(4.45, 1);
expect(totals.gain).toEqual({ value: stages.reduce((sum, stage) => sum + (stage.gain ?? 0), 0), known: 3, of: 3 });
const partial = stageTotalsOf([stages[0], { ...stages[1], minutes: null, distanceKm: null }]);
expect(partial.minutes).toMatchObject({ known: 1, of: 2, value: stages[0].minutes });
expect(partial.distanceKm.known).toBe(1);
expect(stageTotalsOf([{ ...stages[0], gain: null }]).gain).toEqual({ value: null, known: 0, of: 1 });
});
});
@@ -272,3 +272,61 @@ export function tourStagesOf(input: TourStageInputs): TourStage[] {
export function stageBoundariesM(stages: readonly TourStage[]): number[] {
return stages.slice(0, -1).flatMap((stage) => (stage.toM === null ? [] : [stage.toM]));
}
/**
* MDT-02: how long each leg between two neighbouring points runs along the
* line, in metres, as the paddle estimate reads a routed leg; a leg of a
* single vertex has no length the line tells, and is left to the estimate.
*/
export function legLengthsAlong(
points: ReadonlyArray<{ lat: number; lng: number }>,
line: ReadonlyArray<readonly number[]>
): Array<number | null> {
const at = vertexIndicesOf(points, line);
return at.slice(0, -1).map((from, index) => {
const to = at[index + 1];
return to > from ? stretchOf(line, from, to, false).distanceKm * 1000 : null;
});
}
/** A sum over the stages that have the value, with how many have it: null when none does, never a zero for unknown. */
export interface StageSum {
value: number | null;
known: number;
of: number;
}
function stageSum(values: ReadonlyArray<number | null>): StageSum {
const known = values.filter((value): value is number => value !== null);
return {
value: known.length > 0 ? known.reduce((total, value) => total + value, 0) : null,
known: known.length,
of: values.length,
};
}
/** MDT-02: what the stages add up to, each measure only as far as the stages tell it. */
export function stageTotalsOf(stages: readonly TourStage[]) {
return {
distanceKm: stageSum(stages.map((stage) => stage.distanceKm)),
gain: stageSum(stages.map((stage) => stage.gain)),
loss: stageSum(stages.map((stage) => stage.loss)),
minutes: stageSum(stages.map((stage) => stage.minutes)),
};
}
export type StageTotals = ReturnType<typeof stageTotalsOf>;
/**
* MDT-02: the keys of the stops the stages start at, as a day holds a stage
* by them; none for a day tour, and none for a stage that starts at a point
* no save has kept yet.
*/
export function stageStartKeysOf(points: readonly Pick<TourPlannerWaypoint, 'stageEnd' | 'key'>[]): string[] {
const spans = stageSpansOf(points);
if (spans.length < 2) return [];
return spans.flatMap((span) => {
const key = points[span.from]?.key;
return key ? [key] : [];
});
}
@@ -0,0 +1,57 @@
import type { TourListItem, TourWaypoint } from '@trek/shared';
import { useEffect, useMemo, useState } from 'react';
import { tourRepo } from '../../../repo/tourRepo';
import { useTripStore } from '../../../store/tripStore';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { savedStagePoints, savedTourStagesOf, type SavedTourStage } from './savedTourStages';
import { stageTotalsOf, type StageTotals } from './tourStages';
type Loaded = { revision: string; points: readonly TourWaypoint[] } | { revision: string; failed: true };
export interface SavedTourStages {
status: 'idle' | 'loading' | 'ready' | 'error';
stages: SavedTourStage[];
totals: StageTotals | null;
/** The saved points the stages were read off, to name their stops. */
points: TourPlannerWaypoint[];
}
const NONE: SavedTourStages = { status: 'idle', stages: [], totals: null, points: [] };
/**
* MDT-02: the stages of a saved multi-day tour with what each measures, for
* the list and the day plan. The points come through the tour repo, read from
* the cache offline, and are read again once the tour's revision moves on, so
* a saved change to the route shows here at once; the line is the trip's own
* copy of the place, which a change updates too. Nothing is read while
* `enabled` is off (a folded row) or the tour has no stages.
*/
export function useSavedTourStages(tripId: number | string | null | undefined, tour: TourListItem, enabled: boolean): SavedTourStages {
const routeGeometry = useTripStore((state) => state.places.find((place) => place.id === tour.place_id)?.route_geometry);
const revision = `${tour.place_id}:${tour.revision ?? 0}`;
const wanted = enabled && tripId != null && (tour.stage_count ?? 0) > 1;
const [loaded, setLoaded] = useState<Loaded | null>(null);
useEffect(() => {
if (!wanted || tripId == null) return undefined;
const controller = new AbortController();
tourRepo
.detail(tripId, tour.place_id, controller.signal)
.then((detail) => {
if (!controller.signal.aborted) setLoaded({ revision, points: detail.waypoints });
})
.catch(() => {
if (!controller.signal.aborted) setLoaded({ revision, failed: true });
});
return () => controller.abort();
}, [revision, tour.place_id, tripId, wanted]);
return useMemo(() => {
if (!wanted) return NONE;
if (!loaded || loaded.revision !== revision) return { ...NONE, status: 'loading' };
if ('failed' in loaded) return { ...NONE, status: 'error' };
const points = savedStagePoints(tour, loaded.points);
const stages = savedTourStagesOf(tour, points, routeGeometry);
return { status: 'ready', stages, totals: stageTotalsOf(stages), points };
}, [loaded, revision, routeGeometry, tour, wanted]);
}
@@ -0,0 +1,115 @@
// FE-TOUR-STAGE-DAYS-001 to FE-TOUR-STAGE-DAYS-007: each stage of a multi-day tour on a day of its own (MDT-02).
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap } from '../../../types';
import { dayHoldsTour, stageDaysOf, useTourStageDays } from './useTourStageDays';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
/** Tour 42: its first stage on day 3, the whole of tour 7 on day 4, day 5 free. */
const assignments = {
'3': [{ id: 31, place_id: 42, tour_stage_key: 'stop-start' }],
'4': [{ id: 41, place_id: 7, tour_stage_key: null }, { id: 42, place_id: 42, tour_stage_key: null }],
'5': [],
} as unknown as AssignmentsMap;
const assignPlaceToDay = vi.fn(async () => undefined);
const moveAssignment = vi.fn(async () => undefined);
const removeAssignment = vi.fn(async () => undefined);
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useTripStore, { assignments, assignPlaceToDay, moveAssignment, removeAssignment });
});
const render = (canAssign = true) => renderHook(() => useTourStageDays({ tripId: 9, placeId: 42, canAssign }));
describe('stage days', () => {
it('FE-TOUR-STAGE-DAYS-001: reads which day holds which stage, by its starting stop', () => {
expect([...stageDaysOf(42, assignments)]).toEqual([['stop-start', { assignmentId: 31, dayId: 3 }]]);
expect(stageDaysOf(7, assignments).size).toBe(0);
});
it('FE-TOUR-STAGE-DAYS-002: a day holding the tour, whole or a stage, takes no other stage of it', () => {
expect(dayHoldsTour(assignments, 42, 4)).toBe(true);
expect(dayHoldsTour(assignments, 42, 3)).toBe(true);
expect(dayHoldsTour(assignments, 42, 3, { assignmentId: 31, dayId: 3 })).toBe(false);
expect(dayHoldsTour(assignments, 42, 5)).toBe(false);
});
it('FE-TOUR-STAGE-DAYS-003: puts a stage without a day on one, through the plan with its key', async () => {
const { result } = render();
expect(result.current.dayOf('stop-hut')).toBeNull();
expect(result.current.canTake('stop-hut', 5)).toBe(true);
expect(result.current.canTake('stop-hut', 4)).toBe(false);
await act(async () => {
expect(await result.current.setDay('stop-hut', 5)).toBe(true);
});
expect(assignPlaceToDay).toHaveBeenCalledWith(9, 5, 42, null, 'stop-hut');
});
it('FE-TOUR-STAGE-DAYS-004: moves a stage to another day and takes it off with no day', async () => {
const { result } = render();
expect(result.current.dayOf('stop-start')).toBe(3);
await act(async () => {
await result.current.setDay('stop-start', 5);
});
expect(moveAssignment).toHaveBeenCalledWith(9, 31, 3, 5);
await act(async () => {
await result.current.setDay('stop-start', null);
});
expect(removeAssignment).toHaveBeenCalledWith(9, 3, 31);
});
it('FE-TOUR-STAGE-DAYS-005: does nothing for the day it is on, a day that holds the tour, or without the right', async () => {
const { result } = render();
await act(async () => {
expect(await result.current.setDay('stop-start', 3)).toBe(false);
expect(await result.current.setDay('stop-hut', 4)).toBe(false);
expect(await result.current.setDay('stop-hut', null)).toBe(false);
});
const denied = render(false);
expect(denied.result.current.canTake('stop-hut', 5)).toBe(false);
await act(async () => {
expect(await denied.result.current.setDay('stop-hut', 5)).toBe(false);
});
expect(assignPlaceToDay).not.toHaveBeenCalled();
expect(moveAssignment).not.toHaveBeenCalled();
expect(removeAssignment).not.toHaveBeenCalled();
});
it('FE-TOUR-STAGE-DAYS-006: sends a stage once while it is on its way', async () => {
let finish: () => void = () => undefined;
assignPlaceToDay.mockImplementationOnce(() => new Promise<undefined>((resolve) => (finish = () => resolve(undefined))));
const { result } = render();
let first: Promise<boolean> = Promise.resolve(false);
let second: Promise<boolean> = Promise.resolve(false);
act(() => {
first = result.current.setDay('stop-hut', 5);
second = result.current.setDay('stop-hut', 5);
});
expect(await second).toBe(false);
expect(result.current.isPending('stop-hut')).toBe(true);
expect(result.current.canTake('stop-hut', 5)).toBe(false);
await act(async () => {
finish();
expect(await first).toBe(true);
});
expect(assignPlaceToDay).toHaveBeenCalledTimes(1);
expect(result.current.isPending('stop-hut')).toBe(false);
});
it('FE-TOUR-STAGE-DAYS-007: a refused write says so and changes nothing more', async () => {
assignPlaceToDay.mockRejectedValueOnce(new Error('Stage is already on a day'));
const { result } = render();
await act(async () => {
expect(await result.current.setDay('stop-hut', 5)).toBe(false);
});
expect(toast.error).toHaveBeenCalledWith('The stage could not be put on that day.');
expect(result.current.isPending('stop-hut')).toBe(false);
});
});
@@ -0,0 +1,114 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { useTripStore } from '../../../store/tripStore';
import type { AssignmentsMap } from '../../../types';
import { useToast } from '../../shared/Toast';
/** MDT-02: where a stage of a tour is: the assignment that holds it and its day. */
export interface StageDay {
assignmentId: number;
dayId: number;
}
/** The days the stages of one tour are on, by the key of the stop each starts at. */
export function stageDaysOf(placeId: number, assignments: AssignmentsMap): Map<string, StageDay> {
const days = new Map<string, StageDay>();
for (const [dayKey, items] of Object.entries(assignments)) {
for (const item of items) {
if (item.place_id === placeId && item.tour_stage_key) {
days.set(item.tour_stage_key, { assignmentId: item.id, dayId: Number(dayKey) });
}
}
}
return days;
}
/**
* Whether a day holds the tour already, whole or one of its stages, other than
* the stage being placed: a day holds a tour once, as the server holds it to.
*/
export function dayHoldsTour(assignments: AssignmentsMap, placeId: number, dayId: number, own?: StageDay): boolean {
return (assignments[String(dayId)] ?? []).some((item) => item.place_id === placeId && item.id !== own?.assignmentId);
}
interface StageDaysOptions {
tripId: number | string | null | undefined;
placeId: number;
/** Whether the viewer may change the days of the trip. */
canAssign: boolean;
}
/**
* MDT-02: each stage of a multi-day tour on a day of its own, or on none yet.
* Setting a day puts the stage there, moves it from the day it was on, or with
* no day takes it off; all three go the plan's one way (store, repo, queue),
* so they work offline and show at once. A day that already holds the tour or
* another of its stages is not offered, and a stage still on its way cannot be
* sent twice. Stages are found by the key of the stop each starts at, so the
* days stay with their stages however the list groups them.
*/
export function useTourStageDays({ tripId, placeId, canAssign }: StageDaysOptions) {
const { t } = useTranslation();
const toast = useToast();
const { assignments, assignPlaceToDay, moveAssignment, removeAssignment } = useTripStore(
useShallow((state) => ({
assignments: state.assignments,
assignPlaceToDay: state.assignPlaceToDay,
moveAssignment: state.moveAssignment,
removeAssignment: state.removeAssignment,
}))
);
const stageDays = useMemo(() => stageDaysOf(placeId, assignments ?? {}), [assignments, placeId]);
const [pending, setPending] = useState<ReadonlySet<string>>(() => new Set());
// Read before the state renders: two picks in one frame still send once.
const inFlight = useRef(new Set<string>());
const dayOf = useCallback((key: string) => stageDays.get(key)?.dayId ?? null, [stageDays]);
const isPending = useCallback((key: string) => pending.has(key), [pending]);
const canTake = useCallback(
(key: string, dayId: number) =>
canAssign && !pending.has(key) && !dayHoldsTour(assignments ?? {}, placeId, dayId, stageDays.get(key)),
[assignments, canAssign, pending, placeId, stageDays]
);
const write = useCallback(
async (key: string, dayId: number | null, own: StageDay | undefined) => {
if (tripId == null) return;
if (dayId === null && own) await removeAssignment(tripId, own.dayId, own.assignmentId);
else if (dayId !== null && own) await moveAssignment(tripId, own.assignmentId, own.dayId, dayId);
else if (dayId !== null) await assignPlaceToDay(tripId, dayId, placeId, null, key);
},
[assignPlaceToDay, moveAssignment, placeId, removeAssignment, tripId]
);
const setDay = useCallback(
async (key: string, dayId: number | null): Promise<boolean> => {
const own = stageDays.get(key);
if (!canAssign || (own?.dayId ?? null) === dayId || inFlight.current.has(key)) return false;
if (dayId !== null && dayHoldsTour(assignments ?? {}, placeId, dayId, own)) return false;
inFlight.current.add(key);
setPending((current) => new Set(current).add(key));
try {
await write(key, dayId, own);
return true;
} catch {
// The store has put the day plan back as it was; say why nothing changed.
toast.error(t('tours.stages.dayFailed'));
return false;
} finally {
inFlight.current.delete(key);
setPending((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
},
[assignments, canAssign, placeId, stageDays, t, toast, write]
);
return { canAssign, dayOf, isPending, canTake, setDay };
}
export type TourStageDays = ReturnType<typeof useTourStageDays>;
@@ -0,0 +1,95 @@
// FE-TOUR-STAGE-RAIL-001 to FE-TOUR-STAGE-RAIL-005: unfolding a multi-day tour in the planner's list and
// opening one of its stages there (MDT-02).
import type { TourListItem } from '@trek/shared';
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { TourPlannerController } from '../planner/useTourPlanner';
import type { TourStage } from './tourStages';
import { useSavedTourStages } from './useSavedTourStages';
import { useTourStageRail } from './useTourStageRail';
const TOUR = { place_id: 81, name: 'Hut to hut', tour_type: 'hike', stage_count: 2 } as TourListItem;
const SECOND: TourStage = {
number: 2,
from: 1,
to: 2,
fromId: 'p2',
toId: 'p3',
distanceKm: 4,
gain: 300,
loss: 100,
minutes: 120,
fromM: 3000,
toM: 7000,
overDay: false,
};
type Mode = TourPlannerController['mode'];
function plannerOf(mode: Mode, opened = true) {
return {
mode,
waypoints: opened
? [
{ id: 'p1', key: 'stop-start' },
{ id: 'p2', key: 'stop-hut', stageEnd: true },
{ id: 'p3', key: 'stop-end' },
]
: [],
stages: { stages: opened ? [{ ...SECOND, number: 1, from: 0, to: 1, fromId: 'p1', toId: 'p2' }, SECOND] : [] },
routeAnalysis: null,
setSelectedWaypointId: vi.fn(),
setRouteProfileFocus: vi.fn(),
} as unknown as TourPlannerController;
}
describe('useTourStageRail', () => {
it('FE-TOUR-STAGE-RAIL-001: unfolds and folds a tour', () => {
const { result } = renderHook(() => useTourStageRail(plannerOf({ type: 'new-draft' }), vi.fn()));
expect(result.current.isUnfolded(81)).toBe(false);
act(() => result.current.toggle(81));
expect(result.current.isUnfolded(81)).toBe(true);
act(() => result.current.toggle(81));
expect(result.current.isUnfolded(81)).toBe(false);
});
it('FE-TOUR-STAGE-RAIL-002: opens the tour, then shows the stage once its points are in', () => {
const openTour = vi.fn();
let planner = plannerOf({ type: 'new-draft' });
const { result, rerender } = renderHook(() => useTourStageRail(planner, openTour));
act(() => result.current.openStage(TOUR, 'stop-hut'));
expect(openTour).toHaveBeenCalledWith(TOUR);
planner = plannerOf({ type: 'edit-saved', placeId: 81 });
rerender();
expect(planner.setSelectedWaypointId).toHaveBeenCalledWith('p3');
expect(planner.setRouteProfileFocus).toHaveBeenCalledWith(null);
});
it('FE-TOUR-STAGE-RAIL-003: a stage of the tour already open shows at once, without opening it again', () => {
const openTour = vi.fn();
const planner = plannerOf({ type: 'edit-saved', placeId: 81 });
const { result } = renderHook(() => useTourStageRail(planner, openTour));
act(() => result.current.openStage(TOUR, 'stop-start'));
expect(openTour).not.toHaveBeenCalled();
expect(planner.setSelectedWaypointId).toHaveBeenCalledWith('p2');
});
it('FE-TOUR-STAGE-RAIL-004: waits while the open tour stays and lets the stage go once another opens', () => {
let planner = plannerOf({ type: 'edit-saved', placeId: 5 }, false);
const { result, rerender } = renderHook(() => useTourStageRail(planner, vi.fn()));
act(() => result.current.openStage(TOUR, 'stop-hut'));
rerender();
planner = plannerOf({ type: 'edit-saved', placeId: 6 });
rerender();
planner = plannerOf({ type: 'edit-saved', placeId: 81 });
rerender();
expect(planner.setSelectedWaypointId).not.toHaveBeenCalled();
});
it('FE-TOUR-STAGE-RAIL-005: a folded row or a day tour reads no stages', () => {
const folded = renderHook(() => useSavedTourStages(9, TOUR, false));
expect(folded.result.current).toMatchObject({ status: 'idle', stages: [], totals: null });
const dayTour = renderHook(() => useSavedTourStages(9, { ...TOUR, stage_count: null }, true));
expect(dayTour.result.current.status).toBe('idle');
});
});
@@ -0,0 +1,69 @@
import type { TourListItem } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { showStage } from './TourStagesSection';
type RailPlanner = Pick<
TourPlannerController,
'mode' | 'waypoints' | 'stages' | 'routeAnalysis' | 'setSelectedWaypointId' | 'setRouteProfileFocus'
>;
/** A stage asked for while its tour was still being opened: which tour, which stage, and what was open before. */
interface PendingStage {
placeId: number;
key: string;
from: number | null;
}
const openPlaceId = (planner: Pick<TourPlannerController, 'mode'>) =>
planner.mode.type === 'edit-saved' ? planner.mode.placeId : null;
/**
* MDT-02: the multi-day tours unfolded in the planner's list, and a stage
* opened from there: the tour opens in the planner as any tour does (asking
* first when the open draft has unsaved changes), and once its points are in,
* the stage is shown as the planner's own list of stages shows it. A stage of
* the tour already open is shown at once. Asking for another tour, or opening
* another one, lets the stage go.
*/
export function useTourStageRail(planner: RailPlanner, openTour: (tour: TourListItem) => void) {
const [unfolded, setUnfolded] = useState<ReadonlySet<number>>(() => new Set());
const [pending, setPending] = useState<PendingStage | null>(null);
const isUnfolded = useCallback((placeId: number) => unfolded.has(placeId), [unfolded]);
const toggle = useCallback((placeId: number) => {
setUnfolded((current) => {
const next = new Set(current);
if (!next.delete(placeId)) next.add(placeId);
return next;
});
}, []);
const openStage = useCallback(
(tour: TourListItem, key: string) => {
const open = openPlaceId(planner);
setPending({ placeId: tour.place_id, key, from: open });
if (open !== tour.place_id) openTour(tour);
},
[openTour, planner]
);
const open = openPlaceId(planner);
const { waypoints } = planner;
const { stages } = planner.stages;
useEffect(() => {
if (!pending) return;
if (open !== pending.placeId) {
// Another tour opened instead: the stage asked for is no longer wanted.
if (open !== pending.from) setPending(null);
return;
}
const start = waypoints.find((point) => point.key === pending.key);
const stage = start && stages.find((item) => item.fromId === start.id);
if (!stage) return;
showStage(planner, stage);
setPending(null);
}, [open, pending, planner, stages, waypoints]);
return { isUnfolded, toggle, openStage };
}
+7 -3
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
import type { TourStageStops } from '../components/Map/dayTourProjection'
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
import { resolveLegMode } from '../components/Planner/legMode'
import type { RoadtripVia } from '@trek/shared'
@@ -23,8 +24,10 @@ const NO_VIAS: Record<number, RoadtripVia[]> = {}
* `roadtripVias` are the road trip's vias by day: a road picked there is the road this
* day drives too (#2580). Absent, or empty while the road trip addon is off.
*/
export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments'> & { roadtripVias?: Record<number, RoadtripVia[]> }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments'> & { roadtripVias?: Record<number, RoadtripVia[]>; tourStageStops?: TourStageStops }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
const viasByDay = tripStore.roadtripVias ?? NO_VIAS
// MDT-02: a day holding one stage of a multi-day tour routes to that stage.
const tourStageStops = tripStore.tourStageStops
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
@@ -67,6 +70,7 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
optimizeFromAccommodation,
toursEnabled,
places,
tourStageStops,
})
const day = allDays.find(d => d.id === dayId)
// Anchors count the stored list, the hidden lodging and service rows included.
@@ -155,7 +159,7 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viasByDay])
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viasByDay, tourStageStops])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -179,7 +183,7 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
if (!selectedDayId) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viaKey])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viaKey, tourStageStops])
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+3 -2
View File
@@ -127,7 +127,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
invalidateTourPlaceIds, selectedTour,
invalidateTourPlaceIds, selectedTour, tourStageStops,
mapTileUrl, fontStyle, splashDone,
} = pageState.planner
const {
@@ -350,6 +350,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
places={places}
tourPlaceIds={tourPlaceIds}
plannedTourDurations={plannedTourDurations}
tourStageStops={tourStageStops}
categories={categories}
assignments={storedAssignments}
selectedDayId={selectedDayId}
@@ -617,7 +618,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} plannedTourDurations={plannedTourDurations} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} tourPlaceIds={tourPlaceIds} plannedTourDurations={plannedTourDurations} tourStageStops={tourStageStops} categories={categories} assignments={storedAssignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} dayAdd={dayAdd} onDeleteDay={handleDeleteDay} deleteDayQuestion={deleteDayQuestion} onClearDay={can('day_edit', trip) ? handleClearDay : undefined} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onMoveToDay={handleMoveToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, ...leg }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(leg); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} roadtripViasByDay={roadtripVias.byDay} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
: <PlacesSidebar tripId={tripId} places={places} toursEnabled={toursEnabled} excludePlaceIds={toursEnabled ? tourPlaceIds : undefined} categories={categories} assignments={assignments} accommodations={tripAccommodations} selectedDayId={selectedDayId} onClearSelectedDay={() => handleSelectDay(null)} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -20,6 +20,7 @@ import { useResizablePanels } from '../../hooks/useResizablePanels'
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { tourStageStopsByPlace } from '../../components/Map/dayTourProjection'
import { useDayClear } from './useDayClear'
import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
@@ -437,12 +438,14 @@ export function useTripPlanner() {
// overview draw them too, so a road picked in the road trip is the road the
// day drives (#2580).
const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip)
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments, roadtripVias: roadtripVias.byDay }, selectedDayId, routeShown, routeProfile, tripAccommodations)
// MDT-02: where the stages of the multi-day tours run, so a day holding one routes to and draws that stage.
const tourStageStops = useMemo(() => tourStageStopsByPlace(tours), [tours])
const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments, roadtripVias: roadtripVias.byDay, tourStageStops }, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
// down there rather than drawing a second set of lines over it.
const overviewActive = overviewShown && !roadtripMode
const overviewVias = useMemo(() => ({ storedAssignments, viasByDay: roadtripVias.byDay }), [storedAssignments, roadtripVias.byDay])
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places, overviewVias)
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive, places, overviewVias, tourStageStops)
// Called here so the road trip's effects keep their place, right after the overview's.
const {
@@ -675,7 +678,7 @@ export function useTripPlanner() {
startResizeLeft, startResizeRight, nudgeLeft, nudgeRight, resizeMin, resizeMax,
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour, selectedTour,
reloadTourPlaceIds, invalidateTourPlaceIds, upsertTour, selectedTour, tourStageStops,
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
stayPickerDayId, setStayPickerDayId,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
+27 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-ASSIGN-OFF-001 to FE-REPO-ASSIGN-OFF-007
// FE-REPO-ASSIGN-OFF-001 to FE-REPO-ASSIGN-OFF-010
// Offline writes of a day's visits (#2601): put on a day, taken off, reordered,
// moved, who joins and how a leg is travelled.
import 'fake-indexeddb/auto';
@@ -149,4 +149,30 @@ describe('assignmentRepo, offline', () => {
expect(await offlineDb.mutationQueue.count()).toBe(0);
expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500, 520]);
});
it('FE-REPO-ASSIGN-OFF-009: a stage of a multi-day tour goes on its day offline with its key, in the queued write too (MDT-02)', async () => {
const visit = await assignmentRepo.create(4, 71, place, 'stop-hut');
expect(visit).toMatchObject({ day_id: 71, place_id: 300, tour_stage_key: 'stop-hut' });
expect((await offlineDb.days.get(71))?.assignments?.[0].tour_stage_key).toBe('stop-hut');
expect((await queue())[0]).toMatchObject({ body: { place_id: 300, tour_stage_key: 'stop-hut' } });
});
it('FE-REPO-ASSIGN-OFF-010: online, the stage key goes with the request (MDT-02)', async () => {
setOnline(true);
let sent: unknown = null;
server.use(
http.post('/api/trips/4/days/71/assignments', async ({ request }) => {
sent = await request.json();
return HttpResponse.json({
assignment: buildAssignment({ id: 530, day_id: 71, order_index: 0, place, tour_stage_key: 'stop-hut' }),
});
})
);
const visit = await assignmentRepo.create(4, 71, place, 'stop-hut');
expect(sent).toEqual({ place_id: 300, tour_stage_key: 'stop-hut' });
expect(visit).toMatchObject({ id: 530, tour_stage_key: 'stop-hut' });
});
});
+7 -5
View File
@@ -60,11 +60,13 @@ export const assignmentRepo = {
/**
* Puts a place on a day. Offline the visit lands on the cached day under a
* temporary id at the end, and the create waits in the queue; a place created
* offline is named by its real id once that one has synced.
* offline is named by its real id once that one has synced. `tourStageKey`
* puts one stage of a multi-day tour on the day instead of the whole tour (MDT-02).
*/
async create(tripId: number | string, dayId: number, place: Place): Promise<Assignment> {
async create(tripId: number | string, dayId: number, place: Place, tourStageKey?: string): Promise<Assignment> {
const body = { place_id: place.id, ...(tourStageKey ? { tour_stage_key: tourStageKey } : {}) }
if (!isEffectivelyOffline() && place.id > 0) {
const { assignment } = await assignmentsApi.create(tripId, dayId, { place_id: place.id })
const { assignment } = await assignmentsApi.create(tripId, dayId, body)
const saved = { ...assignment, place: assignment.place || place } as Assignment
await offlineDb.days.where('id').equals(dayId).modify(day => {
day.assignments = [...(day.assignments ?? []).filter(a => a.id !== saved.id), saved]
@@ -72,7 +74,7 @@ export const assignmentRepo = {
return saved
}
const tempId = nextTempId()
let visit = { id: tempId, day_id: dayId, place_id: place.id, order_index: 0, notes: null, place } as Assignment
let visit = { id: tempId, day_id: dayId, place_id: place.id, order_index: 0, notes: null, tour_stage_key: tourStageKey ?? null, place } as Assignment
await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => {
await offlineDb.days.where('id').equals(dayId).modify(day => {
const items = day.assignments ?? []
@@ -82,7 +84,7 @@ export const assignmentRepo = {
await mutationQueue.enqueue({
id: generateUUID(), tripId: Number(tripId), method: 'POST',
url: `/trips/${tripId}/days/${dayId}/assignments`,
body: { place_id: place.id }, resource: 'assignments', tempId,
body, resource: 'assignments', tempId,
})
})
mutationQueue.sendSoon()
+5 -3
View File
@@ -16,7 +16,8 @@ export interface AssignmentsSlice {
setAssignmentNotes: (tripId: number | string, dayId: number, assignmentId: number, notes: string | null) => Promise<void>
setAssignmentParticipants: (tripId: number | string, dayId: number, assignmentId: number, userIds: number[]) => Promise<void>
setAssignmentLegMode: (tripId: number | string, dayId: number, assignmentId: number, mode: string | null, direction?: 'outgoing' | 'incoming') => Promise<void>
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
/** `tourStageKey` puts one stage of a multi-day tour on the day instead of the whole tour (MDT-02). */
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null, tourStageKey?: string) => Promise<Assignment | undefined>
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
clearDayAssignments: (tripId: number | string, dayId: number) => Promise<void>
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
@@ -114,7 +115,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, [field]: mode } : a) },
}))
},
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
assignPlaceToDay: async (tripId, dayId, placeId, position, tourStageKey) => {
const state = get()
const place = state.places.find(p => p.id === Number.parseInt(String(placeId)))
if (!place) return
@@ -133,6 +134,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
place_id: place.id,
order_index: insertIdx,
notes: null,
tour_stage_key: tourStageKey ?? null,
place,
}
@@ -145,7 +147,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}))
try {
const created = await assignmentRepo.create(tripId, Number(dayId), place)
const created = await assignmentRepo.create(tripId, Number(dayId), place, tourStageKey)
const newAssignment: Assignment = {
...created,
place: created.place || place,