diff --git a/client/src/components/Map/dayRoutePlan.test.ts b/client/src/components/Map/dayRoutePlan.test.ts index d4a868d1a..e3bcc11e4 100644 --- a/client/src/components/Map/dayRoutePlan.test.ts +++ b/client/src/components/Map/dayRoutePlan.test.ts @@ -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. diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts index 5b0d6ccd7..a7227a58b 100644 --- a/client/src/components/Map/dayRoutePlan.ts +++ b/client/src/components/Map/dayRoutePlan.ts @@ -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 } diff --git a/client/src/components/Map/dayTourProjection.ts b/client/src/components/Map/dayTourProjection.ts index fac690f00..cf137dea3 100644 --- a/client/src/components/Map/dayTourProjection.ts +++ b/client/src/components/Map/dayTourProjection.ts @@ -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 + +/** 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[]): 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 diff --git a/client/src/components/Map/tripRouteGeometry.ts b/client/src/components/Map/tripRouteGeometry.ts index 56e6ede60..6b98b0419 100644 --- a/client/src/components/Map/tripRouteGeometry.ts +++ b/client/src/components/Map/tripRouteGeometry.ts @@ -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) diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts index 02e73ca88..c5c442069 100644 --- a/client/src/components/Map/useTripRouteOverview.ts +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -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 diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx index d2a29f242..f0274637f 100644 --- a/client/src/components/Planner/DayPlanPlaceSubline.tsx +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -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 ; + return ; +} + // 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)}` : ''} )} - {dayRowTour ? : tourTimes && } + {dayRowTour ? : tourTimes && } {dayRowTour && } {description && } diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 656171485..f40e19bd2 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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 plannedTourDurations?: ReadonlyMap + /** 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 )} - + {assignment.notes && ( // Day-specific note on this stop (#2163) — one muted // caption line so the timeline shows the note exists diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index 583d47c48..89f0de651 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -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 + {fold && ( + // Always in sight, unlike the hover actions: the stages are part of what the row is. + event.stopPropagation()}> + + + )} {children} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 7ed2af86b..ba89f124e 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -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() + 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( + @@ -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, 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(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 && ( + + )} + ))} )} diff --git a/client/src/components/Tours/stages/StageDayPicker.tsx b/client/src/components/Tours/stages/StageDayPicker.tsx new file mode 100644 index 000000000..773a01def --- /dev/null +++ b/client/src/components/Tours/stages/StageDayPicker.tsx @@ -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['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) => { + 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 ( + <> + + + + + + ); +} diff --git a/client/src/components/Tours/stages/StageParts.tsx b/client/src/components/Tours/stages/StageParts.tsx index 2fb3318f1..2d96242f9 100644 --- a/client/src/components/Tours/stages/StageParts.tsx +++ b/client/src/components/Tours/stages/StageParts.tsx @@ -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 ( + {number} + {night && } + + ); +} + +/** + * 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>, + 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) => ; + const known = [stage.distanceKm, stage.gain, stage.loss, stage.minutes].some((value) => value !== null); + if (!known && unmeasured) return <>{unmeasured}; return ( {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 ( + <> + + + + {from} + + + + + ); +} + /** * 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 diff --git a/client/src/components/Tours/stages/TourStageDayNotice.tsx b/client/src/components/Tours/stages/TourStageDayNotice.tsx new file mode 100644 index 000000000..9a906d003 --- /dev/null +++ b/client/src/components/Tours/stages/TourStageDayNotice.tsx @@ -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; + +/** + * 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 ( +
+ }> + {t('tours.stages.dayLost', { count: lost })} + +
+ ); +} diff --git a/client/src/components/Tours/stages/TourStageDayPills.tsx b/client/src/components/Tours/stages/TourStageDayPills.tsx new file mode 100644 index 000000000..f1a5414f3 --- /dev/null +++ b/client/src/components/Tours/stages/TourStageDayPills.tsx @@ -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, 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 ( + + + {stage && } + + ); +} diff --git a/client/src/components/Tours/stages/TourStageOverview.tsx b/client/src/components/Tours/stages/TourStageOverview.tsx new file mode 100644 index 000000000..cdb8c0d39 --- /dev/null +++ b/client/src/components/Tours/stages/TourStageOverview.tsx @@ -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['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 ( + + + }>{value} + + + ); +} + +/** + * 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 ( +
+ + + formatDistance(value, unit)} t={t} /> + + + +
+ ); +} + +/** The round button that opens the tour in the planner at one stage. */ +function OpenStageButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + + + ); +} + +/** 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 ( + + + {onOpen && onOpen(stageKey)} />} + + ); +} + +/** + * 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 = ( + + + {t('tours.stages.unmeasured')} + + + ); + return ( + // The row around it opens the tour on a click; what happens in here stays in here. + + ); +} diff --git a/client/src/components/Tours/stages/TourStagesSection.tsx b/client/src/components/Tours/stages/TourStagesSection.tsx index b5b33ff80..f37212617 100644 --- a/client/src/components/Tours/stages/TourStagesSection.tsx +++ b/client/src/components/Tours/stages/TourStagesSection.tsx @@ -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, + 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 ( - - {number} - {night && } - - ); -} - /** * 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 ( 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" > - - - - {nameOf(stage.fromId)} - - - + diff --git a/client/src/components/Tours/stages/savedTourStages.ts b/client/src/components/Tours/stages/savedTourStages.ts new file mode 100644 index 000000000..83de39825 --- /dev/null +++ b/client/src/components/Tours/stages/savedTourStages.ts @@ -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): 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 })); +} diff --git a/client/src/components/Tours/stages/stageOverview.test.tsx b/client/src/components/Tours/stages/stageOverview.test.tsx new file mode 100644 index 000000000..def9cca67 --- /dev/null +++ b/client/src/components/Tours/stages/stageOverview.test.tsx @@ -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(); + 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(); + 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( + + ); + 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(); + expect(await screen.findByText('The stages cannot be read right now.')).toBeInTheDocument(); + view.rerender(); + 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(); + 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(); + 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( +
    + + + +
+ ); + 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 { + 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(); + 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(); + 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(); + const pills = screen.getByTestId('trip-plan-tour-stage'); + expect(pills).toHaveTextContent('Stage 2'); + await waitFor(() => expect(pills).toHaveTextContent('km')); + view.rerender(); + 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>, 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(); + expect(screen.queryByTestId('tour-stage-day-loss')).toBeNull(); + act(() => { + view.rerender( 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(); + 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) => ( + + ); + 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(); + }); +}); diff --git a/client/src/components/Tours/stages/tourStages.test.ts b/client/src/components/Tours/stages/tourStages.test.ts index 7c25899f1..329905ae1 100644 --- a/client/src/components/Tours/stages/tourStages.test.ts +++ b/client/src/components/Tours/stages/tourStages.test.ts @@ -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 }); + }); }); diff --git a/client/src/components/Tours/stages/tourStages.ts b/client/src/components/Tours/stages/tourStages.ts index 7ba2191d6..c6ec62eac 100644 --- a/client/src/components/Tours/stages/tourStages.ts +++ b/client/src/components/Tours/stages/tourStages.ts @@ -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 +): Array { + 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): 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; + +/** + * 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[]): string[] { + const spans = stageSpansOf(points); + if (spans.length < 2) return []; + return spans.flatMap((span) => { + const key = points[span.from]?.key; + return key ? [key] : []; + }); +} diff --git a/client/src/components/Tours/stages/useSavedTourStages.ts b/client/src/components/Tours/stages/useSavedTourStages.ts new file mode 100644 index 000000000..d0bfe3997 --- /dev/null +++ b/client/src/components/Tours/stages/useSavedTourStages.ts @@ -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(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]); +} diff --git a/client/src/components/Tours/stages/useTourStageDays.test.ts b/client/src/components/Tours/stages/useTourStageDays.test.ts new file mode 100644 index 000000000..d78d802d3 --- /dev/null +++ b/client/src/components/Tours/stages/useTourStageDays.test.ts @@ -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((resolve) => (finish = () => resolve(undefined)))); + const { result } = render(); + let first: Promise = Promise.resolve(false); + let second: Promise = 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); + }); +}); diff --git a/client/src/components/Tours/stages/useTourStageDays.ts b/client/src/components/Tours/stages/useTourStageDays.ts new file mode 100644 index 000000000..93b135b92 --- /dev/null +++ b/client/src/components/Tours/stages/useTourStageDays.ts @@ -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 { + const days = new Map(); + 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>(() => new Set()); + // Read before the state renders: two picks in one frame still send once. + const inFlight = useRef(new Set()); + + 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 => { + 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; diff --git a/client/src/components/Tours/stages/useTourStageRail.test.ts b/client/src/components/Tours/stages/useTourStageRail.test.ts new file mode 100644 index 000000000..05e055137 --- /dev/null +++ b/client/src/components/Tours/stages/useTourStageRail.test.ts @@ -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'); + }); +}); diff --git a/client/src/components/Tours/stages/useTourStageRail.ts b/client/src/components/Tours/stages/useTourStageRail.ts new file mode 100644 index 000000000..22288ea7d --- /dev/null +++ b/client/src/components/Tours/stages/useTourStageRail.ts @@ -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) => + 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>(() => new Set()); + const [pending, setPending] = useState(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 }; +} diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 77c9b90ea..6b89cae09 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -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 = {} * `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 & { roadtripVias?: Record }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) { +export function useRouteCalculation(tripStore: Pick & { roadtripVias?: Record; 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(null) const [routeSegments, setRouteSegments] = useState([]) @@ -67,6 +70,7 @@ export function useRouteCalculation(tripStore: Pick d.id === dayId) // Anchors count the stored list, the hidden lodging and service rows included. @@ -155,7 +159,7 @@ export function useRouteCalculation(tripStore: Pick
{mobileSidebarOpen === 'left' - ? { 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 /> + ? { 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 /> : 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 }} /> }
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 030155549..1ad85a89e 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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, diff --git a/client/src/repo/assignmentRepo.offline.test.ts b/client/src/repo/assignmentRepo.offline.test.ts index 3d1dd13c6..f2c1b7548 100644 --- a/client/src/repo/assignmentRepo.offline.test.ts +++ b/client/src/repo/assignmentRepo.offline.test.ts @@ -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' }); + }); }); diff --git a/client/src/repo/assignmentRepo.ts b/client/src/repo/assignmentRepo.ts index a2c1a51b4..2d483e2a8 100644 --- a/client/src/repo/assignmentRepo.ts +++ b/client/src/repo/assignmentRepo.ts @@ -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 { + async create(tripId: number | string, dayId: number, place: Place, tourStageKey?: string): Promise { + 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() diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index 9b8781e0f..3f8b78d07 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -16,7 +16,8 @@ export interface AssignmentsSlice { setAssignmentNotes: (tripId: number | string, dayId: number, assignmentId: number, notes: string | null) => Promise setAssignmentParticipants: (tripId: number | string, dayId: number, assignmentId: number, userIds: number[]) => Promise setAssignmentLegMode: (tripId: number | string, dayId: number, assignmentId: number, mode: string | null, direction?: 'outgoing' | 'incoming') => Promise - assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise + /** `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 removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise clearDayAssignments: (tripId: number | string, dayId: number) => Promise reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise @@ -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, diff --git a/client/tests/unit/slices/assignmentsSlice.test.ts b/client/tests/unit/slices/assignmentsSlice.test.ts index fb86e3348..f935e9666 100644 --- a/client/tests/unit/slices/assignmentsSlice.test.ts +++ b/client/tests/unit/slices/assignmentsSlice.test.ts @@ -71,6 +71,28 @@ describe('assignmentsSlice', () => { expect(dayAssignments.every(a => a.id > 0)).toBe(true); }); + it('FE-ASSIGN-033: a stage of a multi-day tour goes on the day with its key, at once and in the request (MDT-02)', async () => { + const place = buildPlace({ id: 10, trip_id: 1 }); + seedStore(useTripStore, { places: [place], assignments: { '1': [] } }); + let sent: unknown = null; + let optimistic: unknown = null; + server.use( + http.post('/api/trips/1/days/1/assignments', async ({ request }) => { + sent = await request.json(); + optimistic = useTripStore.getState().assignments['1'][0]?.tour_stage_key; + return HttpResponse.json({ + assignment: buildAssignment({ id: 998, day_id: 1, place_id: 10, place, tour_stage_key: 'stop-hut' }), + }); + }) + ); + + await useTripStore.getState().assignPlaceToDay(1, 1, 10, null, 'stop-hut'); + + expect(sent).toEqual({ place_id: 10, tour_stage_key: 'stop-hut' }); + expect(optimistic).toBe('stop-hut'); + expect(useTripStore.getState().assignments['1'][0]).toMatchObject({ id: 998, tour_stage_key: 'stop-hut' }); + }); + it('FE-ASSIGN-003: on API failure, temp assignment is removed (rollback)', async () => { const place = buildPlace({ id: 10, trip_id: 1 }); seedStore(useTripStore, { diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index c6337a994..acdd0936a 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -612,6 +612,7 @@ export const PLUGIN_ENTITY_FIELDS = { 'incoming_leg_transport_mode', 'route_excluded', 'accommodation_id', + 'tour_stage_key', 'created_at', 'participants', 'tour_place_id', diff --git a/server/src/db/entities/DayAssignments.entity.ts b/server/src/db/entities/DayAssignments.entity.ts index 9520df133..095397dbe 100644 --- a/server/src/db/entities/DayAssignments.entity.ts +++ b/server/src/db/entities/DayAssignments.entity.ts @@ -30,6 +30,7 @@ export class DayAssignments { end_day: number & Opt = 0; accommodation_id?: number | null; route_excluded: number & Opt = 0; + tour_stage_key?: string | null; assignment_participants_collection = new Collection(this); file_links_collection = new Collection(this); reservations_collection = new Collection(this); @@ -42,6 +43,13 @@ export class DayAssignments { export const DayAssignmentsSchema = defineEntity({ class: DayAssignments, repository: () => DayAssignmentsRepository, + indexes: [ + { + name: 'idx_day_assignments_tour_stage', + where: 'tour_stage_key IS NOT NULL', + properties: ['place_id', 'tour_stage_key'], + }, + ], properties: { id: p.integer().primary(), day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_assignments_day_id'), @@ -61,6 +69,7 @@ export const DayAssignmentsSchema = defineEntity({ end_day: p.integer().default(0), accommodation_id: p.integer().nullable().index('idx_day_assignments_accommodation_id'), route_excluded: p.integer().default(0), + tour_stage_key: p.text().nullable(), assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('assignment').hidden(), file_links_collection: () => p.oneToMany(FileLinks).mappedBy('assignment').hidden(), reservations_collection: () => p.oneToMany(Reservations).mappedBy('assignment').hidden(), diff --git a/server/src/db/kysely/DayAssignments.table.ts b/server/src/db/kysely/DayAssignments.table.ts index d75d832c5..3997489de 100644 --- a/server/src/db/kysely/DayAssignments.table.ts +++ b/server/src/db/kysely/DayAssignments.table.ts @@ -19,4 +19,5 @@ export interface DayAssignmentsTable { end_day: InsertOptional; accommodation_id: InsertOptional; route_excluded: InsertOptional; + tour_stage_key: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046010_a_day_can_hold_one_stage_of_a_tour.ts b/server/src/db/migrations/Migration20200101046010_a_day_can_hold_one_stage_of_a_tour.ts new file mode 100644 index 000000000..bba6ba578 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046010_a_day_can_hold_one_stage_of_a_tour.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * MDT-02: a day of the plan can hold one stage of a multi-day tour instead of + * the whole tour, named by the key of the stop the stage starts at (the key a + * save keeps for every point of a tour). NULL, as on every assignment made + * before, is the whole tour or an ordinary place. The index finds the day a + * stage is on, and the stages a changed tour no longer has. + */ +export class Migration20200101046010_a_day_can_hold_one_stage_of_a_tour extends Migration { + override name = 'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour'; + + override up(): void { + this.addSql(`ALTER TABLE day_assignments ADD COLUMN tour_stage_key TEXT`); + this.addSql( + `CREATE INDEX idx_day_assignments_tour_stage ON day_assignments (place_id, tour_stage_key) WHERE tour_stage_key IS NOT NULL`, + ); + } +} diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts index 94480d24e..629b29944 100644 --- a/server/src/db/repositories/DayAssignments.repository.ts +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -105,6 +105,8 @@ export interface DayAssignmentRow { end_day: number; accommodation_id: number | null; route_excluded: number; + /** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for anything else. */ + tour_stage_key: string | null; } const _dayAssignmentRowKeys: AssertRowKeys = true; @@ -433,6 +435,8 @@ export class DayAssignmentsRepository extends TrekRepository { order_index: number; notes: string | null; accommodation_id: number | null; + /** MDT-02: one stage of a multi-day tour, by its starting stop's key; absent for the whole tour or a place. */ + tour_stage_key?: string | null; }): Promise { return await this.insert({ day: input.day_id, @@ -440,9 +444,43 @@ export class DayAssignmentsRepository extends TrekRepository { order_index: input.order_index, notes: input.notes, accommodation_id: input.accommodation_id, + tour_stage_key: input.tour_stage_key ?? null, }); } + /** MDT-02: the assignment holding this stage of a tour, with its day; `undefined` while the stage is on no day. */ + async findTourStage(place_id: number, tour_stage_key: string): Promise<{ id: number; day_id: number } | undefined> { + return await this.kysely>() + .selectFrom('day_assignments') + .select(['id', 'day_id']) + .where('place_id', '=', place_id) + .where('tour_stage_key', '=', tour_stage_key) + .executeTakeFirst(); + } + + /** + * MDT-02: takes off their days the stages of a tour whose starting stop is + * not among `keys` (every stage, for none), and answers what it took, so a + * stage a changed tour no longer has leaves no day holding it. The caller + * owns the transaction: the tour's points are written in the same one. + */ + async deleteTourStagesNotIn(place_id: number, keys: readonly string[]): Promise> { + let query = this.kysely>() + .selectFrom('day_assignments') + .select(['id', 'day_id']) + .where('place_id', '=', place_id) + .where('tour_stage_key', 'is not', null); + if (keys.length > 0) query = query.where('tour_stage_key', 'not in', [...keys]); + const gone = await query.orderBy('id', 'asc').execute(); + if (gone.length > 0) { + await this.kysely>() + .deleteFrom('day_assignments') + .where('id', 'in', gone.map((row) => row.id)) + .execute(); + } + return gone; + } + /** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */ async deleteById(id: number): Promise { await this.nativeDelete({ id }); @@ -706,6 +744,8 @@ export class DayAssignmentsRepository extends TrekRepository { assignment_time: string | null; assignment_end_time: string | null; end_day: number; + /** MDT-02: the copied tour keeps its stops' keys, so a day holding one of its stages keeps holding it. */ + tour_stage_key?: string | null; }): Promise { return await this.insert({ day: input.day_id, @@ -718,6 +758,7 @@ export class DayAssignmentsRepository extends TrekRepository { assignment_time: input.assignment_time, assignment_end_time: input.assignment_end_time, end_day: input.end_day, + tour_stage_key: input.tour_stage_key ?? null, }); } diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 91d303198..f0d5c2ce7 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -219,12 +219,24 @@ type ToursKyselyDB = Pick< >; /** One day a tour of the trip sits on, in the order of the trip. */ +/** MDT-02: a point of a tour as the stages a day can hold are read from it. */ +export interface TourStagePointRow { + role: 'start' | 'via' | 'end'; + lat: number; + lng: number; + key?: string; + stage_end?: boolean; + shaping?: boolean; +} + export interface TourDayRow { place_id: number; day_id: number; day_number: number; date: string | null; title: string | null; + /** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; null for the whole tour. */ + tour_stage_key: string | null; } /** @@ -557,7 +569,7 @@ export class ToursRepository extends TrekRepository { .execute(); } - /** The days every tour of a trip sits on, each tour and day once, in day order. */ + /** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */ async listTourDays(trip_id: number): Promise { return await this.db() .selectFrom('day_assignments as da') @@ -569,6 +581,7 @@ export class ToursRepository extends TrekRepository { 'd.day_number as day_number', 'd.date as date', 'd.title as title', + 'da.tour_stage_key as tour_stage_key', ]) .where('d.trip_id', '=', trip_id) .distinct() @@ -624,6 +637,30 @@ export class ToursRepository extends TrekRepository { return !!(await query.executeTakeFirst()); } + /** + * MDT-02: the points of a tour as its stages are read from them, in route + * order: role, key, stage end and where each sits. None for a place that is + * no tour, or a tour without points of its own. + */ + async listStagePoints(place_id: number): Promise { + const rows = await this.db() + .selectFrom('tour_waypoints') + .select(['role', 'station_key', 'stage_end', 'shaping', 'lat', 'lng']) + .where('place_id', '=', place_id) + .orderBy('sequence', 'asc') + // The column's CHECK holds it to the three roles. + .$narrowType<{ role: TourStagePointRow['role'] }>() + .execute(); + return rows.map((row) => ({ + role: row.role, + lat: row.lat, + lng: row.lng, + ...(row.station_key ? { key: row.station_key } : {}), + ...(row.stage_end ? { stage_end: true } : {}), + ...(row.shaping ? { shaping: true } : {}), + })); + } + /** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */ async listRowsForTrip(trip_id: number): Promise { return await this.db() diff --git a/server/src/nest/assignments/assignments-domain.module.ts b/server/src/nest/assignments/assignments-domain.module.ts index 6c6107579..5ca7c65f8 100644 --- a/server/src/nest/assignments/assignments-domain.module.ts +++ b/server/src/nest/assignments/assignments-domain.module.ts @@ -33,7 +33,8 @@ import { Module } from '@nestjs/common'; * `DaysModule`/`PlacesModule`/`RoadtripModule` modules themselves, so the * loop this module's docstring already avoids stays avoided (`DaysModule`'s * own precedent for pulling in `Trips` the same way). `Tours` backs the - * one-Tour-per-day check create and move share. + * one-Tour-per-day check create and move share, and the stages of a + * multi-day tour a day can hold (MDT-02). */ @Module({ imports: [ diff --git a/server/src/nest/assignments/assignments.controller.ts b/server/src/nest/assignments/assignments.controller.ts index 4128cc6df..1c9800f25 100644 --- a/server/src/nest/assignments/assignments.controller.ts +++ b/server/src/nest/assignments/assignments.controller.ts @@ -56,7 +56,9 @@ export class DayAssignmentsController { if (!(await this.assignments.placeExists(body.place_id, tripId))) { throw new HttpException({ error: 'Place not found' }, 404); } - const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes); + const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes, { + ...(body.tour_stage_key !== undefined ? { tourStageKey: body.tour_stage_key } : {}), + }); this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId); await this.assignments.reconcile(tripId, socketId); return { assignment }; diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index cd5a3cabd..c773adcf6 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -13,7 +13,12 @@ import { import { DaysService } from '../days/days.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { AssignmentsService } from './assignments.service'; -import { idSchema, assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared'; +import { + idSchema, + assignmentCreateRequestSchema, + assignmentEndDayRequestSchema, + type AssignmentEndDayRequest, +} from '@trek/shared'; import { z } from 'zod'; @@ -36,25 +41,37 @@ export class AssignmentsMcp { @Tool({ name: 'assign_place_to_day', - description: 'Assign a place to a specific day in a trip.', + description: + 'Assign a place to a specific day in a trip. For a multi-day tour, pass tourStageKey to put one of its stages on the day instead of the whole tour: the key of the stop the stage starts at, from the stage_stops list_tours gives (every stop but the last starts a stage). A stage is on one day at most, and a day holds a tour, or one of its stages, once.', inputSchema: { tripId: idSchema, dayId: idSchema, placeId: idSchema, notes: z.string().max(500).optional(), + tourStageKey: assignmentCreateRequestSchema.shape.tour_stage_key.describe( + 'The key of the stop a stage of a multi-day tour starts at, to put that stage on the day instead of the whole tour', + ), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'places', mode: 'write' }, }) async assignPlaceToDay( - { tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string }, + { + tripId, + dayId, + placeId, + notes, + tourStageKey, + }: { tripId: number; dayId: number; placeId: number; notes?: string; tourStageKey?: string }, ctx: McpContext, ) { if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.'); if (!(await this.assignments.placeExists(placeId, tripId))) return errorResult('Place not found.'); - const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null); + const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null, { + ...(tourStageKey !== undefined ? { tourStageKey } : {}), + }); this.guards.safeBroadcast(tripId, 'assignment:created', { assignment }); await this.assignments.reconcile(tripId); return ok({ assignment }); diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index bfcb9e795..495e47ef6 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -14,6 +14,7 @@ import type { ToursRepository } from '../../db/repositories/Tours.repository'; import type { TripMembersRepository } from '../../db/repositories/TripMembers.repository'; import type { TripAccess } from '../../db/repositories/Trips.repository'; import type { User } from '../../types'; +import { DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { formatAssignmentWithPlace } from '../common/rowShape'; import { UnitOfWork } from '../database/unit-of-work'; @@ -24,7 +25,13 @@ import { RealtimeService } from '../realtime/realtime.service'; import { TripAccessService } from '../trip-membership/trip-access.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { ConflictException, Injectable } from '@nestjs/common'; -import { chronoOrder, type RoadtripVia, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared'; +import { + chronoOrder, + tourStageKeysOf, + type RoadtripVia, + type TrekWsPayload, + type TrekWsTripEventName, +} from '@trek/shared'; import { isEmptyReanchoring, reanchorByStopOrder } from '@trek/shared/roadtrip'; type Trip = TripAccess; @@ -261,12 +268,14 @@ export class AssignmentsService { * afterwards: the row this returns is what the answer hands the client, and a * stop that reaches it without its booking id is one the day list cannot tell * from a place the traveller added, so it draws the hotel a second time. + * @param opts.tourStageKey MDT-02: one stage of a multi-day tour instead of + * the whole tour, by the key of the stop it starts at. */ async createAssignment( dayId: string | number, placeId: unknown, notes?: string | null, - opts: { accommodationId?: number; orderIndex?: number } = {}, + opts: { accommodationId?: number; orderIndex?: number; tourStageKey?: string } = {}, ) { // Downstream of the caller's own dayExists/placeExists gate in the same // request (every real call site), so the non-null assertion is @@ -276,6 +285,7 @@ export class AssignmentsService { const insertedId = await this.uow.transactional(async () => { await this.assertTourFreeOnDay(dayIdNum, placeIdNum); + if (opts.tourStageKey !== undefined) await this.assertTourStageFree(placeIdNum, opts.tourStageKey); // AS6 — the explicit null check (not `||`) is load-bearing: a stored 0 // order_index must survive, which `maxOrder || -1` would not. const maxOrder = await this.dayAssignmentsRepo.maxOrderIndex(dayIdNum); @@ -295,6 +305,7 @@ export class AssignmentsService { order_index: orderIndex, notes: notes || null, accommodation_id: opts.accommodationId ?? null, + tour_stage_key: opts.tourStageKey ?? null, }); }); @@ -395,6 +406,30 @@ export class AssignmentsService { } } + /** + * MDT-02: a stage goes on a day only as a stage the tour has (read off its + * saved points, so a place that is no tour has none) and on one day at most; + * moving it to another day is a move, not a second stage. Asked inside the + * transaction that writes it, as the one-tour-a-day check is. + */ + private async assertTourStageFree(placeId: number, key: string): Promise { + if (!tourStageKeysOf(await this.toursRepo.listStagePoints(placeId)).includes(key)) { + throw new DomainError(400, 'Tour has no such stage', { code: 'tour_stage_unknown' }); + } + if (await this.dayAssignmentsRepo.findTourStage(placeId, key)) { + throw new DomainError(409, 'Stage is already on a day', { code: 'tour_stage_taken' }); + } + } + + /** + * MDT-02: takes the stages a saved change of a tour no longer has off their + * days, inside the caller's transaction, and answers which, so the caller + * can tell the trip once the change is committed. + */ + async dropStaleTourStages(placeId: number, keys: readonly string[]): Promise> { + return await this.dayAssignmentsRepo.deleteTourStagesNotIn(placeId, keys); + } + /** AS15 — `AssignmentParticipantsRepository.listWithDisplayName`, the same AS2/AS31 statement. */ async getParticipants(assignmentId: string | number) { return await this.assignmentParticipantsRepo.listWithDisplayName(toRowId(assignmentId)!); diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index 2dded97da..ed7498cf2 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -50,6 +50,8 @@ export function formatAssignmentWithPlace( created_at: a.created_at, tour_place_id: a.tour_place_id ?? null, tour_route_geometry: a.tour_route_geometry ?? null, + // MDT-02: the stage of a multi-day tour the day holds; null for the whole tour or a place. + tour_stage_key: a.tour_stage_key ?? null, place: { id: a.place_id, name: a.place_name, diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index a0b94be55..1f1bb08ff 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -206,6 +206,7 @@ export class DaysService { created_at: a.created_at, tour_place_id: a.tour_place_id ?? null, tour_route_geometry: a.tour_route_geometry ?? null, + tour_stage_key: a.tour_stage_key ?? null, place: { id: a.place_id, name: a.place_name, diff --git a/server/src/nest/plugins/protocol/output-contract.ts b/server/src/nest/plugins/protocol/output-contract.ts index 9e7ce20c3..06303f3f6 100644 --- a/server/src/nest/plugins/protocol/output-contract.ts +++ b/server/src/nest/plugins/protocol/output-contract.ts @@ -269,6 +269,7 @@ export const PLUGIN_ENTITY_CONTRACT = { 'incoming_leg_transport_mode', 'route_excluded', 'accommodation_id', + 'tour_stage_key', 'created_at', ], withheld: [], diff --git a/server/src/nest/tours/tour-change-save.service.ts b/server/src/nest/tours/tour-change-save.service.ts index c5aa1eb91..a4bb10ac9 100644 --- a/server/src/nest/tours/tour-change-save.service.ts +++ b/server/src/nest/tours/tour-change-save.service.ts @@ -136,7 +136,7 @@ export class TourChangeSaveService { // Laid once more on the tour as it is now: a tour moved on, a station done or a place gone refuses here. const now = await this.preview.lay(tripId, placeId, changes, { action: 'saved', preparedOn }); const before = await this.beforeState(tripId, placeId, now); - const { revision, visitsDropped } = await this.tours.replaceRoute(tripId, placeId, write); + const { revision, visitsDropped, stagesDropped } = await this.tours.replaceRoute(tripId, placeId, write); const id = await this.stepsRepo.record({ place_id: placeId, proposal_id: proposalId, @@ -146,10 +146,10 @@ export class TourChangeSaveService { before_state: before, saved_by: userId, }); - return { step: { id, revision }, visitsDropped }; + return { step: { id, revision }, visitsDropped, stagesDropped }; }); if ('earlier' in saved) return alreadySavedAnswer(saved.earlier); - const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved.visitsDropped); + const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, saved); return savedAnswer(detail, saved.step, changed); } diff --git a/server/src/nest/tours/tour-change-undo.service.ts b/server/src/nest/tours/tour-change-undo.service.ts index 069e5a3d1..dd3835a1f 100644 --- a/server/src/nest/tours/tour-change-undo.service.ts +++ b/server/src/nest/tours/tour-change-undo.service.ts @@ -177,7 +177,7 @@ export class TourChangeUndoService { } return { step, ...written }; }); - const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored.visitsDropped); + const detail = await this.tours.announceRouteUpdate(String(tripId), placeId, restored); return { undone: true as const, step: { id: restored.step.id, revision: restored.step.revision, undone_revision: restored.revision }, diff --git a/server/src/nest/tours/tour-library.service.ts b/server/src/nest/tours/tour-library.service.ts index 37c8d5c52..dc8289a3d 100644 --- a/server/src/nest/tours/tour-library.service.ts +++ b/server/src/nest/tours/tour-library.service.ts @@ -78,7 +78,7 @@ export class TourLibraryService { const needle = query.q?.toLocaleLowerCase(); const matching = needle ? rows.filter((row) => row.name.toLocaleLowerCase().includes(needle)) : rows; const shown = matching.slice(0, TOUR_LIBRARY_LIMIT); - const walkPoints = await this.tours.walkPointsByPlace(shown); + const walkPoints = await this.tours.listedPointsByPlace(shown); return { tours: shown.map((row) => toItem(row, walkPoints.get(row.place_id))), trips, @@ -90,7 +90,7 @@ export class TourLibraryService { const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId); if (!row) throw new NotFoundException('Tour not found'); const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]); - const walkPoints = await this.tours.walkPointsByPlace([row]); + const walkPoints = await this.tours.listedPointsByPlace([row]); return { tour: toItem(row, walkPoints.get(row.place_id)), line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS), diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index be6ace22a..d766f280b 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -21,7 +21,7 @@ import { type TourPoiSearchRequest, } from '@trek/shared'; -type TourDay = Pick; +type TourDay = Pick; const poiFields = tourPoiSearchRequestSchema.shape; @@ -101,7 +101,7 @@ export class ToursMcp { @Tool({ name: 'list_tours', description: - 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on.', + 'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).', inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 6cd0d7008..ad747d132 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -9,6 +9,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import { Tours } from '../../db/entities/Tours.entity'; import { AddonsModule } from '../addons/addons.module'; +import { AssignmentsDomainModule } from '../assignments/assignments-domain.module'; import { MapsModule } from '../maps/maps.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { OverpassModule } from '../overpass/overpass.module'; @@ -104,6 +105,8 @@ import { Module } from '@nestjs/common'; PaddleObstaclesModule, // A city walk prepared over MCP is planned on foot by the router the road trip uses (CW-11). RoadtripModule, + // A saved change takes the stages a tour no longer has off their days (MDT-02). + AssignmentsDomainModule, MikroOrmModule.forFeature([ Tours, TourTypes, diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 6c060ab35..d08fa99af 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -23,6 +23,7 @@ import type { } from '../../db/repositories/Tours.repository'; import { utcSuffix } from '../../db/types'; import { decodeJson, encodeJson } from '../../utils/json-column'; +import { AssignmentsService } from '../assignments/assignments.service'; import { badRequest, DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { UnitOfWork } from '../database/unit-of-work'; @@ -67,6 +68,8 @@ import { type TourWaypoint, tourStageCountOf, tourStageIssues, + tourStageKeysOf, + tourStageStopsOf, type WandererDifficulty, type WinterActivity, walkStationFacts, @@ -109,6 +112,12 @@ export interface TourRouteWrite { fields: TourUpdate; } +/** What a route write took along: the visits of stations that are gone, and the days that held a stage that is gone (MDT-02). */ +export interface TourRouteDrops { + visitsDropped: readonly number[]; + stagesDropped: ReadonlyArray<{ id: number; day_id: number }>; +} + /** The way on foot the server's router planned for a city walk (CW-11): its line, and how long it walks in seconds. */ interface CityWalkFootRoute { line: Array<[number, number]>; @@ -155,6 +164,12 @@ function keyedWaypoints(waypoints: TourWaypoint[]): Array[0]): TourListItem['stage_stops'] { + const stops = tourStageStopsOf(points); + return stops.length > 0 ? stops : null; +} + @Injectable() export class ToursService { private readonly logger = new Logger(ToursService.name); @@ -170,6 +185,7 @@ export class ToursService { @InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository, private readonly placeImport: PlaceImportService, private readonly paddleChecks: TourPaddleCheckService, + private readonly assignments: AssignmentsService, ) {} /** @@ -224,6 +240,8 @@ export class ToursService { ...activityFacts(r.place_id, activities), // MDT-01: a multi-day tour's stages, one more than the stops that end one. stage_count: r.stage_ends > 0 ? r.stage_ends + 1 : null, + // MDT-02: the stops they run between, by which a day holds one of them. + stage_stops: r.stage_ends > 0 ? stageStopsOrNull(points) : null, }; } @@ -317,25 +335,28 @@ export class ToursService { const tid = toRowId(tripId); if (tid === null) return []; const rows = await this.toursRepo.listForTrip(tid); - const walkPoints = await this.walkPointsByPlace(rows); + const listedPoints = await this.listedPointsByPlace(rows); const activities = await this.activitiesRepo.listForTrip(tid); return await this.paddleChecks.forTrip( tid, - rows.map((r) => this.toItem(r, walkPoints.get(r.place_id) ?? [], activities)), + rows.map((r) => this.toItem(r, listedPoints.get(r.place_id) ?? [], activities)), ); } /** - * CW-02, CW-03: the points of the city walks among these tours, by tour, for - * a list that counts their stations and stays; the points of another type - * are not read. + * The points a list reads, by tour: a city walk's, which it counts the + * stations and stays of (CW-02, CW-03), and a multi-day tour's, which it + * reads the stops between the stages off (MDT-02). The points of any other + * tour are not read. */ - async walkPointsByPlace( - rows: readonly { place_id: number; tour_type: string }[], + async listedPointsByPlace( + rows: readonly { place_id: number; tour_type: string; stage_ends?: number }[], ): Promise> { const byPlace = new Map(); - const walks = rows.filter((row) => row.tour_type === 'walk').map((row) => row.place_id); - for (const point of await this.waypointsRepo.listForPlaces(walks)) { + const read = rows + .filter((row) => row.tour_type === 'walk' || (row.stage_ends ?? 0) > 0) + .map((row) => row.place_id); + for (const point of await this.waypointsRepo.listForPlaces(read)) { const points = byPlace.get(point.place_id); if (points) points.push(point); else byPlace.set(point.place_id, [point]); @@ -545,7 +566,7 @@ export class ToursService { const pid = toRowId(placeId); if (tid === null || pid === null) throw new NotFoundException('Tour not found'); - await this.uow.transactional(async () => { + const stagesDropped = await this.uow.transactional(async () => { const row = await this.toursRepo.findInTrip(tid, pid); if (!row) throw new NotFoundException('Tour not found'); const recorded = await this.toursRepo.findRecordedTrack(tid, pid); @@ -595,6 +616,8 @@ export class ToursService { }); await this.waypointsRepo.deleteForPlace(pid); await this.toursRepo.clearRecordedTrack(pid); + // MDT-02: back to the recorded track the tour has no stages, so no day holds one. + return await this.assignments.dropStaleTourStages(pid, []); }); const result = await this.getTour(tripId, placeId); @@ -606,6 +629,7 @@ export class ToursService { } catch { this.logger.warn(`Committed Tour ${placeId}: place notification failed`); } + await this.announceStagesDropped(tripId, stagesDropped); return result; } @@ -758,8 +782,8 @@ export class ToursService { const pid = toRowId(placeId); if (tid === null || pid === null) throw new NotFoundException('Tour not found'); const write = await this.routeWrite(input); - const { visitsDropped } = await this.uow.transactional(() => this.replaceRoute(tid, pid, write)); - return await this.announceRouteUpdate(tripId, pid, visitsDropped, socketId); + const dropped = await this.uow.transactional(() => this.replaceRoute(tid, pid, write)); + return await this.announceRouteUpdate(tripId, pid, dropped, socketId); } /** @@ -788,7 +812,7 @@ export class ToursService { tripId: number, placeId: number, { input, start, fields }: TourRouteWrite, - ): Promise<{ revision: number; visitsDropped: number[] }> { + ): Promise<{ revision: number } & TourRouteDrops> { // The trip predicate is the cross-trip boundary. It runs inside the write // so an id from another accessible trip cannot be moved into the current // one, and a place deleted meanwhile answers 404 instead of failing a @@ -813,14 +837,19 @@ export class ToursService { await this.waypointsRepo.insertForPlace(placeId, waypoints); // CW-03: a station that is gone takes its visits with it; a tour that is no city walk keeps none. const stations = input.tour_type === 'walk' ? walkStationsOf(waypoints).map((point) => point.key) : []; - return { revision, visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations) }; + return { + revision, + visitsDropped: await this.visitsRepo.dropStationsNotIn(placeId, stations), + // MDT-02: a stage the change took away (its stage end gone, the tour a day tour again) leaves its day. + stagesDropped: await this.assignments.dropStaleTourStages(placeId, tourStageKeysOf(waypoints)), + }; } - /** After a route write committed: the tour read back, and the trip told of it and of the visits it took along. */ + /** After a route write committed: the tour read back, and the trip told of it and of the visits and stages it took along. */ async announceRouteUpdate( tripId: string, placeId: number, - visitsDropped: readonly number[], + { visitsDropped, stagesDropped }: TourRouteDrops, socketId?: string, ): Promise { const result = await this.getTour(tripId, String(placeId)); @@ -833,9 +862,30 @@ export class ToursService { this.logger.warn(`Committed Tour ${placeId}: place notification failed`); } await this.announceVisits(tripId, visitsDropped); + await this.announceStagesDropped(tripId, stagesDropped); return result; } + /** + * MDT-02: the days that held a stage a change took away, told to every + * client of the trip, the one that saved included, since its day plan still + * shows them; the journey follows the days as after any unassign. + */ + private async announceStagesDropped( + tripId: string, + dropped: ReadonlyArray<{ id: number; day_id: number }>, + ): Promise { + if (dropped.length === 0) return; + for (const { id, day_id } of dropped) { + try { + this.places.broadcast(tripId, 'assignment:deleted', { assignmentId: id, dayId: day_id }, undefined); + } catch { + this.logger.warn(`Committed Tour stage change: realtime notification of assignment ${id} failed`); + } + } + await this.assignments.reconcile(tripId); + } + /** * CW-03: the days whose visits an edit took along, told to every client of * the trip, the one that saved included, so no day plan goes on counting a diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 6424f1e0d..8d5c27ca3 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -768,6 +768,7 @@ export class TripsService { reservation_status: a.reservation_status, reservation_notes: a.reservation_notes, reservation_datetime: a.reservation_datetime, assignment_time: a.assignment_time, assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0, + tour_stage_key: a.tour_stage_key, }); assignmentMap.set(a.id, newAssignmentId); } diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 8651f5d8c..82233ec35 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -39,7 +39,7 @@ import { insertRows, updateRows, } from '../helpers/factories/rows'; -import { makeTour } from '../helpers/factories/tours'; +import { addTourWaypoints, makeTour } from '../helpers/factories/tours'; import { makeUser } from '../helpers/factories/users'; import { FakeRealtimeService } from '../helpers/fake-realtime'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; @@ -283,6 +283,35 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { } }); + it('MDT-E2E-001: 201 puts a stage of a multi-day tour on a day, 409 a second day, 400 a stage it has not, 400 a malformed key', async () => { + const tourPlaceId = 21; + await insertRow(orm, Places, { id: tourPlaceId, trip: 5, name: 'Hut to hut' }); + await makeTour(orm, tourPlaceId, { tourTypeRef: 'hike' }); + await addTourWaypoints(orm, tourPlaceId, [ + { lat: 47, lng: 11, station_key: 'stop-start' }, + { lat: 47.1, lng: 11, station_key: 'stop-hut', stage_end: 1 }, + { lat: 47.2, lng: 11, station_key: 'stop-end' }, + ]); + const post = (dayId: number, body: Record) => + request(server).post(`/api/trips/5/days/${dayId}/assignments`).set('Cookie', sessionCookie(1)).send(body); + try { + const ok = await post(3, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' }); + expect(ok.status).toBe(201); + expect(ok.body.assignment).toMatchObject({ day_id: 3, tour_place_id: tourPlaceId, tour_stage_key: 'stop-hut' }); + expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: ok.body.assignment }, undefined); + + const twice = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-hut' }); + expect([twice.status, twice.body]).toEqual([409, { error: 'Stage is already on a day' }]); + const none = await post(4, { place_id: tourPlaceId, tour_stage_key: 'stop-end' }); + expect([none.status, none.body]).toEqual([400, { error: 'Tour has no such stage' }]); + const malformed = await post(4, { place_id: tourPlaceId, tour_stage_key: 'no' }); + expect(malformed.status).toBe(400); + expect(await countRows(orm, DayAssignments, { place: tourPlaceId })).toBe(1); + } finally { + await dropTourPlace(tourPlaceId); + } + }); + it('200 delete assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); const id = await seedAssignment(); diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index da4afc44a..ab88e404c 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -374,21 +374,22 @@ export interface TestDayAssignment { place_id: number; order_index: number; notes: string | null; + tour_stage_key: string | null; } export function createDayAssignment( db: Database.Database, dayId: number, placeId: number, - overrides: Partial<{ order_index: number; notes: string }> = {}, + overrides: Partial<{ order_index: number; notes: string; tour_stage_key: string }> = {}, ): TestDayAssignment { const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null; }; const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0); const result = db - .prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)') - .run(dayId, placeId, orderIndex, overrides.notes ?? null); + .prepare('INSERT INTO day_assignments (day_id, place_id, order_index, notes, tour_stage_key) VALUES (?, ?, ?, ?, ?)') + .run(dayId, placeId, orderIndex, overrides.notes ?? null, overrides.tour_stage_key ?? null); return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment; } diff --git a/server/tests/helpers/fake-tour-stage-days.ts b/server/tests/helpers/fake-tour-stage-days.ts new file mode 100644 index 000000000..993d4338c --- /dev/null +++ b/server/tests/helpers/fake-tour-stage-days.ts @@ -0,0 +1,19 @@ +/** + * The part of AssignmentsService that ToursService asks when a saved change + * takes stages of a multi-day tour off their days (MDT-02), for suites that + * build ToursService by hand. Given the day assignments repository it takes + * the rows off for real, so a suite on the test database sees them go; + * without one there is nothing to take off. The journey refresh after it is + * left out: the assignments suites cover it. + */ +import type { DayAssignmentsRepository } from '../../src/db/repositories/DayAssignments.repository'; +import type { AssignmentsService } from '../../src/nest/assignments/assignments.service'; + +export function tourStageDays(dayAssignments?: DayAssignmentsRepository): AssignmentsService { + const stages = { + dropStaleTourStages: async (placeId: number, keys: readonly string[]) => + dayAssignments ? await dayAssignments.deleteTourStagesNotIn(placeId, keys) : [], + reconcile: async () => undefined, + }; + return stages as unknown as AssignmentsService; +} diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 803c9db15..dc5aafa9f 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -75,6 +75,7 @@ const UNNUMBERED = [ 'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with', 'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities', 'Migration20200101046000_a_tour_waypoint_can_end_a_stage', + 'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts index 2636d5d5b..d98950411 100644 --- a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts @@ -1341,3 +1341,79 @@ describe('DayAssignmentsRepository.listPublicForShare (SH9, share.service.ts)', expect(await assignments.listPublicForShare([])).toEqual([]); }); }); + +describe('DayAssignmentsRepository tour stages (MDT-02)', () => { + /** A tour on a trip with three days, the first holding nothing yet. */ + function tourOnTrip() { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const days = [createDay(testDb, trip.id), createDay(testDb, trip.id), createDay(testDb, trip.id)]; + const place = createPlace(testDb, trip.id); + createTour(testDb, place.id); + return { trip, days, place }; + } + + const stageOf = async (id: number) => (await findRow(t, DayAssignments, { id }))?.tour_stage_key; + + it('ASSIGNREPO-043: writes the stage a day holds, and none for the whole tour', async () => { + const { days, place } = tourOnTrip(); + const base = { place_id: place.id, order_index: 0, notes: null, accommodation_id: null }; + const stage = await assignments.insertAssignment({ ...base, day_id: days[0].id, tour_stage_key: 'stage-one' }); + const whole = await assignments.insertAssignment({ ...base, day_id: days[1].id }); + expect(await stageOf(stage)).toBe('stage-one'); + expect(await stageOf(whole)).toBeNull(); + }); + + it('ASSIGNREPO-044: finds the day a stage is on, and nothing for a stage on none or of another tour', async () => { + const { trip, days, place } = tourOnTrip(); + const held = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' }); + expect(await assignments.findTourStage(place.id, 'stage-two')).toEqual({ id: held.id, day_id: days[1].id }); + expect(await assignments.findTourStage(place.id, 'stage-one')).toBeUndefined(); + const other = createPlace(testDb, trip.id); + expect(await assignments.findTourStage(other.id, 'stage-two')).toBeUndefined(); + }); + + it('ASSIGNREPO-045: takes off only the stages not kept, leaving the whole tour and other tours alone', async () => { + const { trip, days, place } = tourOnTrip(); + const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' }); + const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' }); + const whole = createDayAssignment(testDb, days[2].id, place.id); + const other = createPlace(testDb, trip.id); + const otherStage = createDayAssignment(testDb, days[2].id, other.id, { tour_stage_key: 'stage-two' }); + + const gone = await uow.transactional(() => assignments.deleteTourStagesNotIn(place.id, ['stage-one'])); + expect(gone).toEqual([{ id: second.id, day_id: days[1].id }]); + const left = (await findRows(t, DayAssignments, {}, { id: 'asc' })).map((row) => row.id); + expect(left).toEqual([first.id, whole.id, otherStage.id]); + }); + + it('ASSIGNREPO-046: takes every stage off with no key kept, and writes nothing when no stage is held', async () => { + const { days, place } = tourOnTrip(); + const first = createDayAssignment(testDb, days[0].id, place.id, { tour_stage_key: 'stage-one' }); + const second = createDayAssignment(testDb, days[1].id, place.id, { tour_stage_key: 'stage-two' }); + expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([ + { id: first.id, day_id: days[0].id }, + { id: second.id, day_id: days[1].id }, + ]); + expect(await assignments.deleteTourStagesNotIn(place.id, [])).toEqual([]); + }); + + it('ASSIGNREPO-047: a copied assignment keeps the stage it held', async () => { + const { days, place } = tourOnTrip(); + const copy = { + place_id: place.id, + order_index: 0, + notes: null, + reservation_status: null, + reservation_notes: null, + reservation_datetime: null, + assignment_time: null, + assignment_end_time: null, + end_day: 0, + }; + const stage = await assignments.insertAssignmentCopy({ ...copy, day_id: days[0].id, tour_stage_key: 'stage-one' }); + const whole = await assignments.insertAssignmentCopy({ ...copy, day_id: days[1].id }); + expect(await stageOf(stage)).toBe('stage-one'); + expect(await stageOf(whole)).toBeNull(); + }); +}); diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index e20d69304..b2fa5fa41 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -263,6 +263,44 @@ describe('ToursRepository reads', () => { }); }); +describe('ToursRepository stages on days (MDT-02)', () => { + it('TOURREPO-017: listStagePoints reads the points in route order with key, role and stage end, none for a plain place', async () => { + const { trip } = fixture(); + const hutToHut = await makePlace(t, trip.id, { name: 'Hut to hut' }); + const plain = await makePlace(t, trip.id, { name: 'Plain' }); + createTour(testDb, hutToHut.id); + await insertRows(t, TourWaypoints, [ + { place: hutToHut.id, lat: 47.2, lng: 11, role: 'end', sequence: 3, station_key: 'stop-end' }, + { place: hutToHut.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'stop-start' }, + { place: hutToHut.id, lat: 47.1, lng: 11, role: 'via', sequence: 2, station_key: 'stop-hut', stage_end: 1 }, + { place: hutToHut.id, lat: 47.05, lng: 11, role: 'via', sequence: 1, shaping: 1 }, + ]); + + expect(await repo.listStagePoints(hutToHut.id)).toEqual([ + { role: 'start', lat: 47, lng: 11, key: 'stop-start' }, + { role: 'via', lat: 47.05, lng: 11, shaping: true }, + { role: 'via', lat: 47.1, lng: 11, key: 'stop-hut', stage_end: true }, + { role: 'end', lat: 47.2, lng: 11, key: 'stop-end' }, + ]); + expect(await repo.listStagePoints(plain.id)).toEqual([]); + }); + + it('TOURREPO-018: listTourDays names the stage each day holds, null where a day holds the whole tour', async () => { + const { trip } = fixture(); + const tour = createPlace(testDb, trip.id); + createTour(testDb, tour.id); + const first = createDay(testDb, trip.id); + const second = createDay(testDb, trip.id); + createDayAssignment(testDb, first.id, tour.id); + createDayAssignment(testDb, second.id, tour.id, { tour_stage_key: 'stop-hut' }); + + expect((await repo.listTourDays(trip.id)).map((row) => [row.day_id, row.tour_stage_key])).toEqual([ + [first.id, null], + [second.id, 'stop-hut'], + ]); + }); +}); + describe('ToursRepository writes', () => { it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => { const { trip } = fixture(); diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts index 9aa2456f0..9efb5c864 100644 --- a/server/tests/unit/mcp/tools-assignments.test.ts +++ b/server/tests/unit/mcp/tools-assignments.test.ts @@ -6,6 +6,7 @@ import { db as testDb } from '../../../src/db/database'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { createUser, createTrip, @@ -15,11 +16,12 @@ import { createJourney, } from '../../helpers/factories'; import { linkTripToJourney } from '../../helpers/factories/journeys'; -import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows'; import { FakeRealtimeService } from '../../helpers/fake-realtime'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { createTour } from '../../helpers/tours-repos'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -108,6 +110,49 @@ describe('Tool: assign_place_to_day', () => { }); }); + it('MDT-MCP-001: puts one stage of a multi-day tour on a day, refusing as REST does', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const secondDay = createDay(testDb, trip.id); + const tour = createPlace(testDb, trip.id); + createTour(testDb, tour.id); + await insertRows( + orm, + TourWaypoints, + ['stop-start', 'stop-hut', 'stop-end'].map((station_key, sequence) => ({ + place: tour.id, + lat: 47 + sequence / 10, + lng: 11, + role: sequence === 0 ? 'start' : sequence === 2 ? 'end' : 'via', + sequence, + station_key, + stage_end: sequence === 1 ? 1 : 0, + })), + ); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip.id, dayId: day.id, placeId: tour.id, tourStageKey: 'stop-hut' }, + }); + expect(parseToolResult(result)).toMatchObject({ assignment: { day_id: day.id, tour_stage_key: 'stop-hut' } }); + const again = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-hut' }, + }); + expect(again.isError).toBe(true); + expect(JSON.stringify(again.content)).toContain('Stage is already on a day'); + const unknown = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip.id, dayId: secondDay.id, placeId: tour.id, tourStageKey: 'stop-end' }, + }); + expect(unknown.isError).toBe(true); + expect(JSON.stringify(unknown.content)).toContain('Tour has no such stage'); + }); + expect(await countRows(orm, DayAssignments, { place: tour.id })).toBe(1); + }); + it('assigns a place to a day', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/assignments.controller.test.ts b/server/tests/unit/nest/assignments.controller.test.ts index 8becd7a03..5d3c75b3c 100644 --- a/server/tests/unit/nest/assignments.controller.test.ts +++ b/server/tests/unit/nest/assignments.controller.test.ts @@ -69,11 +69,23 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route expect(await new DayAssignmentsController(s).create(user, '5', '3', { place_id: 2, notes: 'n' }, 'sock')).toEqual( { assignment: { id: 9 } }, ); - expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n'); + expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n', {}); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock'); expect(reconcile).toHaveBeenCalledWith('5', 'sock'); }); + it('MDT-CTRL-001: hands a stage of a multi-day tour to the service, the same as the MCP tool', async () => { + const createAssignment = vi.fn().mockReturnValue({ id: 9, tour_stage_key: 'stop-hut' }); + const broadcast = vi.fn(); + const reconcile = vi.fn().mockResolvedValue(undefined); + const s = svc({ createAssignment, broadcast, reconcile } as Partial); + const body = { place_id: 2, tour_stage_key: 'stop-hut' }; + expect(await new DayAssignmentsController(s).create(user, '5', '3', body, 'sock')).toEqual({ + assignment: { id: 9, tour_stage_key: 'stop-hut' }, + }); + expect(createAssignment).toHaveBeenCalledWith('3', 2, undefined, { tourStageKey: 'stop-hut' }); + }); + it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => { const broadcast = vi.fn(); const reconcile = vi.fn(); @@ -85,7 +97,7 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({ status: 409, }); - expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined); + expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined, {}); expect(broadcast).not.toHaveBeenCalled(); expect(reconcile).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index eb7d85c33..4bdce3279 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -13,12 +13,14 @@ import { db as testDb } from '../../../src/db/database'; import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { Days } from '../../../src/db/entities/Days.entity'; import { Places } from '../../../src/db/entities/Places.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { type TripAccess } from '../../../src/db/repositories/Trips.repository'; import { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; +import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service'; @@ -36,7 +38,7 @@ import { } from '../../helpers/factories'; import { makeDayAccommodation } from '../../helpers/factories/itinerary'; import { tagPlace } from '../../helpers/factories/places'; -import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../helpers/factories/rows'; import { createTestJourneysRepo, createTestJourneyContributorsRepo, @@ -377,6 +379,111 @@ describe('createAssignment', () => { }); }); +// ── stages of a multi-day tour on days (MDT-02) ───────────────────────────── + +describe('createAssignment with a stage of a multi-day tour (MDT-02)', () => { + /** A tour of three stages: its start, two huts that end a stage, its end. */ + async function hutToHut(placeId: number) { + await addTourFacet(placeId); + await insertRows( + await orm(), + TourWaypoints, + ['stop-start', 'stop-hut-one', 'stop-hut-two', 'stop-end'].map((station_key, sequence) => ({ + place: placeId, + lat: 47 + sequence / 10, + lng: 11, + role: sequence === 0 ? 'start' : sequence === 3 ? 'end' : 'via', + sequence, + station_key, + stage_end: sequence === 1 || sequence === 2 ? 1 : 0, + })), + ); + } + + it('ASG-SVC-051: puts a stage on a day by the stop it starts at, and the answer and the day list say which', async () => { + const { day, place } = fixture(); + await hutToHut(place.id); + const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' }); + expect(stage).toMatchObject({ tour_place_id: place.id, tour_stage_key: 'stop-hut-one' }); + expect((await svc.listDayAssignments(day.id)).map((a) => a.tour_stage_key)).toEqual(['stop-hut-one']); + }); + + it('ASG-SVC-052: a whole tour and an ordinary place carry no stage', async () => { + const { trip, day, place } = fixture(); + await hutToHut(place.id); + const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }); + expect(await svc.createAssignment(day.id, place.id)).toMatchObject({ tour_stage_key: null }); + expect(await svc.createAssignment(day.id, ordinary.id)).toMatchObject({ tour_stage_key: null }); + }); + + it('ASG-SVC-053: refuses a stage the tour does not have: its last stop, a stop ending none, a plain place, a day tour', async () => { + const { trip, day, place } = fixture(); + await hutToHut(place.id); + const plain = createPlace(testDb, trip.id, { name: 'Ordinary place' }); + const dayTour = createPlace(testDb, trip.id, { name: 'Day hike' }); + await addTourFacet(dayTour.id); + await insertRows(await orm(), TourWaypoints, [ + { place: dayTour.id, lat: 47, lng: 11, role: 'start', sequence: 0, station_key: 'day-start' }, + { place: dayTour.id, lat: 47.1, lng: 11, role: 'end', sequence: 1, station_key: 'day-end' }, + ]); + for (const [placeId, key] of [ + [place.id, 'stop-end'], + [place.id, 'stop-unknown'], + [plain.id, 'stop-start'], + [dayTour.id, 'day-start'], + ] as const) { + const refusal = await catchDomainError(() => svc.createAssignment(day.id, placeId, null, { tourStageKey: key })); + expect(refusal instanceof DomainError && [refusal.getStatus(), refusal.publicMessage]).toEqual([ + 400, + 'Tour has no such stage', + ]); + } + expect(await countRows(await orm(), DayAssignments, {})).toBe(0); + }); + + it('ASG-SVC-054: a stage is on one day at most, and a day holds a tour or one of its stages once', async () => { + const { trip, day, place } = fixture(); + await hutToHut(place.id); + const secondDay = createDay(testDb, trip.id); + await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' }); + const taken = await catchDomainError(() => + svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-start' }), + ); + expect(taken instanceof DomainError && [taken.getStatus(), taken.publicMessage]).toEqual([ + 409, + 'Stage is already on a day', + ]); + await expect(svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-one' })).rejects.toThrow( + ConflictException, + ); + // Another stage on another day is fine. + await expect( + svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' }), + ).resolves.toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-one' }); + }); + + it('ASG-SVC-055: a stage keeps its key when it moves to another day', async () => { + const { trip, day, place } = fixture(); + await hutToHut(place.id); + const secondDay = createDay(testDb, trip.id); + const stage = await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-hut-two' }); + const { assignment } = await svc.moveAssignment(stage!.id, secondDay.id, 0); + expect(assignment).toMatchObject({ day_id: secondDay.id, tour_stage_key: 'stop-hut-two' }); + }); + + it('ASG-SVC-056: dropStaleTourStages takes off the stages not kept and answers which', async () => { + const { trip, day, place } = fixture(); + await hutToHut(place.id); + const secondDay = createDay(testDb, trip.id); + await svc.createAssignment(day.id, place.id, null, { tourStageKey: 'stop-start' }); + const second = await svc.createAssignment(secondDay.id, place.id, null, { tourStageKey: 'stop-hut-one' }); + expect(await svc.dropStaleTourStages(place.id, ['stop-start'])).toEqual([ + { id: second!.id, day_id: secondDay.id }, + ]); + expect(await countRows(await orm(), DayAssignments, { place: place.id })).toBe(1); + }); +}); + // ── listDayAssignments ──────────────────────────────────────────────────────── describe('listDayAssignments', () => { diff --git a/server/tests/unit/nest/common/rowShape.test.ts b/server/tests/unit/nest/common/rowShape.test.ts index 2b5a100bd..2ca4fe64a 100644 --- a/server/tests/unit/nest/common/rowShape.test.ts +++ b/server/tests/unit/nest/common/rowShape.test.ts @@ -56,6 +56,7 @@ function makeRow(overrides: Partial = {}): AssignmentWit fill_percent: null, tour_place_id: null, tour_route_geometry: null, + tour_stage_key: null, ...overrides, }; } @@ -118,6 +119,12 @@ describe('formatAssignmentWithPlace', () => { expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry }); }); + it('MDT-SHAPE-001: carries the stage of a multi-day tour a day holds, null for the whole tour', () => { + expect(formatAssignmentWithPlace(makeRow(), [], []).tour_stage_key).toBeNull(); + const stage = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_stage_key: 'stop-hut' }), [], []); + expect(stage.tour_stage_key).toBe('stop-hut'); + }); + it('passes tags and participants through untouched', () => { const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants); expect(result.place.tags).toEqual(sampleTags); diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index d0b5c724c..3edab28cf 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -30,6 +30,7 @@ import { accommodationsOver } from '../../helpers/accommodations-service'; import { createTrip, createUser } from '../../helpers/factories'; import { countRows, findRows } from '../../helpers/factories/rows'; import { noPaddleChecks } from '../../helpers/fake-paddle-checks'; +import { tourStageDays } from '../../helpers/fake-tour-stage-days'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { noGoogleQuota } from '../../helpers/google-quota'; import { @@ -176,6 +177,7 @@ beforeAll(async () => { await createTestTourActivitiesRepo(testDb), buildPlaceImportService(), noPaddleChecks(), + tourStageDays(), ); }); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts index 3271061c7..e43093826 100644 --- a/server/tests/unit/nest/tours.mcp.test.ts +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -86,8 +86,8 @@ describe('ToursMcp', () => { const listedRidge = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id); expect(listedRidge).toMatchObject({ name: 'Ridge', origin: expect.any(String), max_hiking_difficulty: 3 }); expect(listedRidge.days).toEqual([ - { day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive' }, - { day_id: day2.id, day_number: 2, date: '2026-07-11', title: null }, + { day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive', tour_stage_key: null }, + { day_id: day2.id, day_number: 2, date: '2026-07-11', title: null, tour_stage_key: null }, ]); expect(tours.find((tour: { place_id: number }) => tour.place_id === gorge.id).days).toEqual([]); }); @@ -363,6 +363,28 @@ describe('ToursMcp', () => { ]); }); + it('TOURS-MCP-016: list_tours names the stops between the stages and the stage each day holds (MDT-02)', async () => { + const { user, trip, ridge, day1, day2 } = fixture(); + const orm = await createTestOrm(db); + await addTourWaypoints(orm, ridge.id, [ + { lat: 47, lng: 11, station_key: 'stop-start' }, + { lat: 47.05, lng: 11.05, station_key: 'stop-hut', stage_end: 1 }, + { lat: 47.1, lng: 11.1, station_key: 'stop-end' }, + ]); + await orm.close(); + const day3 = createDay(db, trip.id, { day_number: 3 }); + createDayAssignment(db, day3.id, ridge.id, { tour_stage_key: 'stop-hut' }); + + const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id))); + const listed = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id); + expect(listed.stage_stops.map((stop: { key: string }) => stop.key)).toEqual(['stop-start', 'stop-hut', 'stop-end']); + expect(listed.days.map((day: { day_id: number; tour_stage_key: string | null }) => [day.day_id, day.tour_stage_key])).toEqual([ + [day1.id, null], + [day2.id, null], + [day3.id, 'stop-hut'], + ]); + }); + it('TOURS-MCP-014: the tools name the station fields, and the deletes ask before a walk takes its protected stations along', () => { const description = (ctor: unknown, method: string) => { const entry = getEntry(ctor as ClassRef, method); diff --git a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts index fb8756b26..fd58b051e 100644 --- a/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts +++ b/server/tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts @@ -26,6 +26,7 @@ import type { StorageService } from '../../../src/nest/storage/storage.service'; import { ToursService } from '../../../src/nest/tours/tours.service'; import type { User } from '../../../src/types'; import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks'; +import { tourStageDays } from '../../helpers/fake-tour-stage-days'; import { buildPlaceImportService } from '../../helpers/place-import'; import { keyed } from '../../helpers/tour-keys'; import type { EntityManager } from '@mikro-orm/core'; @@ -313,6 +314,7 @@ function tourRepositoryFixture(ownerTripId: number) { noActivities(), buildPlaceImportService(), noPaddleChecks(), + tourStageDays(), ); return { uow, toursRepo, waypointsRepo, placesRepo, places, service }; } diff --git a/server/tests/unit/nest/tours.service.permissions.test.ts b/server/tests/unit/nest/tours.service.permissions.test.ts index 549d947f9..8c07203d1 100644 --- a/server/tests/unit/nest/tours.service.permissions.test.ts +++ b/server/tests/unit/nest/tours.service.permissions.test.ts @@ -14,6 +14,7 @@ import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import type { PlacesService } from '../../../src/nest/places/places.service'; import { ToursService } from '../../../src/nest/tours/tours.service'; import { noActivities, noPaddleChecks } from '../../helpers/fake-paddle-checks'; +import { tourStageDays } from '../../helpers/fake-tour-stage-days'; import { buildPlaceImportService } from '../../helpers/place-import'; import { keyed } from '../../helpers/tour-keys'; import { NotFoundException } from '@nestjs/common'; @@ -148,6 +149,7 @@ function makeService() { noActivities(), buildPlaceImportService(), noPaddleChecks(), + tourStageDays(), ); return { uow, toursRepo, waypointsRepo, visitsRepo, placesRepo, places, place, service }; } diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index 8e5626e8b..6eb6c56c9 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -8,18 +8,25 @@ * real PlacesService, is covered by tours.gpx.atomic.test.ts. */ import { db as testDb } from '../../../src/db/database'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; import { Places } from '../../../src/db/entities/Places.entity'; import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { Tours } from '../../../src/db/entities/Tours.entity'; import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error'; import type { PlacesService } from '../../../src/nest/places/places.service'; import { ToursService } from '../../../src/nest/tours/tours.service'; -import { createPlace, createTrip, createUser } from '../../helpers/factories'; +import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories'; import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows'; import { noPaddleChecks } from '../../helpers/fake-paddle-checks'; +import { tourStageDays } from '../../helpers/fake-tour-stage-days'; import { buildPlaceImportService } from '../../helpers/place-import'; import { resetTestDb } from '../../helpers/test-db'; -import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; +import { + createTestDayAssignmentsRepo, + createTestPlacesRepo, + createTestUnitOfWork, + sharedTestOrm, +} from '../../helpers/test-uow'; import { keyed } from '../../helpers/tour-keys'; import { createTestTourActivitiesRepo, @@ -90,6 +97,7 @@ beforeAll(async () => { await createTestTourActivitiesRepo(testDb), buildPlaceImportService(), noPaddleChecks(), + tourStageDays(await createTestDayAssignmentsRepo(testDb)), ); }); @@ -748,3 +756,111 @@ describe('ToursService multi-day tours (MDT-01)', () => { expect(await count(Tours)).toBe(0); }); }); + +describe('ToursService stages on days (MDT-02)', () => { + /** Three days hut to hut: the stops at the second and third point end a stage. */ + const hutToHut: TourCreateRequest = { + ...request, + planned_duration_minutes: null, + break_additional_minutes: 60, + duration_seconds: 30 * 3600, + route_geometry: [ + [48, 11, 600], + [48.01, 11.02, 650], + [48.02, 11.04, 630], + [48.03, 11.06, 700], + ], + waypoints: [ + { lat: 48, lng: 11, role: 'start', sequence: 0 }, + { lat: 48.01, lng: 11.02, role: 'via', sequence: 1, stage_end: true }, + { lat: 48.02, lng: 11.04, role: 'via', sequence: 2, stage_end: true }, + { lat: 48.03, lng: 11.06, role: 'end', sequence: 3 }, + ], + }; + + /** The tour saved, and each of its three stages on a day of its own. */ + async function onThreeDays() { + const created = await service.createTour(tripId, hutToHut); + const placeId = created.tour.place_id; + const keys = created.waypoints.map((point) => String(point.key)); + const days = [1, 2, 3].map(() => createDay(testDb, Number(tripId))); + const held = [0, 1, 2].map( + (stage) => createDayAssignment(testDb, days[stage].id, placeId, { tour_stage_key: keys[stage] }).id, + ); + broadcast.mockReset(); + return { created, placeId, keys, days, held }; + } + + const heldStages = async (placeId: number) => + (await findRows(await sharedTestOrm(testDb), DayAssignments, { place: placeId }, { id: 'asc' })).map( + (row) => row.tour_stage_key, + ); + + it('MDT-SVC-004: lists the stops the stages run between, keyed, and none for a day tour', async () => { + const created = await service.createTour(tripId, hutToHut); + const keys = created.waypoints.map((point) => point.key); + expect(created.tour.stage_stops).toEqual( + hutToHut.waypoints.map((point, index) => ({ key: keys[index], lat: point.lat, lng: point.lng })), + ); + const dayTour = await service.createTour(tripId, request); + expect(dayTour.tour.stage_stops).toBeNull(); + const listed = new Map((await service.listTours(tripId)).map((tour) => [tour.place_id, tour.stage_stops])); + expect(listed.get(created.tour.place_id)?.map((stop) => stop.key)).toEqual(keys); + expect(listed.get(dayTour.tour.place_id)).toBeNull(); + expect((await service.getTour(tripId, String(created.tour.place_id))).tour.stage_stops).toHaveLength(4); + }); + + it('MDT-SVC-005: a saved change takes only the stages it took away off their days, and tells every client', async () => { + const { created, placeId, keys, days, held } = await onThreeDays(); + // The third point no longer ends a stage: the stage that started there is gone, the others keep their keys. + const joined = created.waypoints.map(({ stage_end: _end, ...point }, index) => + index === 1 ? { ...point, stage_end: true } : point, + ); + await service.updateTour(tripId, String(placeId), { ...hutToHut, waypoints: joined }, 'socket'); + expect(await heldStages(placeId)).toEqual([keys[0], keys[1]]); + expect(broadcast).toHaveBeenCalledWith( + tripId, + 'assignment:deleted', + { assignmentId: held[2], dayId: days[2].id }, + undefined, + ); + // Back to a day tour, no day holds a stage of it any more. + await service.updateTour(tripId, String(placeId), request); + expect(await heldStages(placeId)).toEqual([]); + }); + + it('MDT-SVC-006: a change that keeps every stage leaves every day as it was and says nothing', async () => { + const { created, placeId, keys } = await onThreeDays(); + await service.updateTour(tripId, String(placeId), { ...hutToHut, name: 'Renamed', waypoints: created.waypoints }); + expect(await heldStages(placeId)).toEqual(keys.slice(0, 3)); + expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toEqual([]); + }); + + it('MDT-SVC-007: going back to the recorded track takes every stage off its day', async () => { + const { placeId } = await onThreeDays(); + await updateRows( + await sharedTestOrm(testDb), + Tours, + { place: placeId }, + { recorded_route_geometry: JSON.stringify(hutToHut.route_geometry) }, + ); + (await sharedTestOrm(testDb)).clear(); + await service.restoreRecordedTrack(tripId, String(placeId)); + expect(await heldStages(placeId)).toEqual([]); + expect(broadcast.mock.calls.filter(([, event]) => event === 'assignment:deleted')).toHaveLength(3); + }); + + it('MDT-SVC-008: a stage notice that cannot be sent never fails the saved change', async () => { + const { created, placeId } = await onThreeDays(); + broadcast.mockImplementation((_tripId: string, event: string) => { + if (event === 'assignment:deleted') throw new Error('socket closed'); + }); + const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const dayTour = created.waypoints.map(({ stage_end: _end, ...point }) => point); + await expect( + service.updateTour(tripId, String(placeId), { ...hutToHut, duration_seconds: 3600, waypoints: dayTour }), + ).resolves.toMatchObject({ tour: { stage_count: null } }); + expect(await heldStages(placeId)).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/shared/src/assignment/assignment.schema.spec.ts b/shared/src/assignment/assignment.schema.spec.ts index 5159a00b9..876e80c59 100644 --- a/shared/src/assignment/assignment.schema.spec.ts +++ b/shared/src/assignment/assignment.schema.spec.ts @@ -28,6 +28,9 @@ describe('assignment Tours read-model merge', () => { incoming_leg_transport_mode: 'cycling', }; expect(assignmentSchema.parse({ ...ordinary, ...fields })).toMatchObject(fields); + // MDT-02: the stage of a multi-day tour the day holds, null for the whole tour. + expect(assignmentSchema.parse({ ...ordinary, tour_stage_key: 'stop-1xx' }).tour_stage_key).toBe('stop-1xx'); + expect(assignmentSchema.parse({ ...ordinary, tour_stage_key: null }).tour_stage_key).toBeNull(); expect(assignmentSchema.parse({ ...ordinary, tour_place_id: null, tour_route_geometry: null })).toMatchObject({ tour_place_id: null, tour_route_geometry: null, @@ -55,6 +58,16 @@ describe('assignmentCreateRequestSchema', () => { expect(assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null }).success).toBe(true); expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false); }); + + it('MDT-SH-012: takes the stage of a multi-day tour by the key of the stop it starts at', () => { + expect(assignmentCreateRequestSchema.parse({ place_id: 2, tour_stage_key: 'stop-1xx' })).toEqual({ + place_id: 2, + tour_stage_key: 'stop-1xx', + }); + for (const tour_stage_key of ['', 'short', 'not a key!', null, 3]) { + expect(assignmentCreateRequestSchema.safeParse({ place_id: 2, tour_stage_key }).success).toBe(false); + } + }); }); describe('assignmentMoveRequestSchema', () => { diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 352204652..66637b38b 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -1,4 +1,5 @@ import { assignmentPlaceSchema } from '../place/place.schema'; +import { stationKeySchema } from '../tours/tours.schema'; import { z } from 'zod'; @@ -57,6 +58,11 @@ export const assignmentSchema = z.object({ accommodation_id: z.number().nullable().optional(), tour_place_id: z.number().nullable().optional(), tour_route_geometry: z.string().nullable().optional(), + /** + * MDT-02: the stage of a multi-day tour this day holds, by the key of the + * stop the stage starts at; null where the day holds the whole tour or a place. + */ + tour_stage_key: z.string().nullable().optional(), participants: z.array(assignmentParticipantSchema).optional(), created_at: z.string().optional(), place: assignmentPlaceSchema, @@ -66,6 +72,12 @@ export type Assignment = z.infer; export const assignmentCreateRequestSchema = z.object({ place_id: z.union([z.number(), z.string()]), notes: z.string().nullable().optional(), + /** + * MDT-02: puts one stage of a multi-day tour on the day instead of the whole + * tour, by the key of the stop the stage starts at (the tour list's + * `stage_stops`). A stage is on one day at most. + */ + tour_stage_key: stationKeySchema.optional(), }); export type AssignmentCreateRequest = z.infer; diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 10d22f5e7..3aba72b00 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -1326,5 +1326,22 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.many': 'نهايات المراحل التي ستُزال: {count}. يبقى المسار كما هو، والتراجع يعيدها.', 'tours.stages.toDayConfirm': 'إزالة نهايات المراحل', 'tours.stages.show': 'إظهار مكان نهاية هذه المرحلة', + 'tours.stages.fold': 'إخفاء المراحل', + 'tours.stages.unfold': 'إظهار المراحل', + 'tours.stages.noDay': 'بلا يوم', + 'tours.stages.noDayHint': 'لم تُضف بعد إلى أي يوم من الرحلة', + 'tours.stages.dayFailed': 'تعذّر وضع المرحلة في ذلك اليوم.', + 'tours.stages.dayLost': 'مراحل لها يوم لم تعد موجودة: {count}. الحفظ يزيلها من أيامها.', + 'tours.stages.dayLost.zero': 'مراحل لها يوم لم تعد موجودة: {count}. الحفظ يزيلها من أيامها.', + 'tours.stages.dayLost.one': 'مرحلة لها يوم لم تعد موجودة: {count}. الحفظ يزيلها من يومها.', + 'tours.stages.dayLost.two': 'مرحلتان لهما يوم لم تعودا موجودتين: {count}. الحفظ يزيلهما من يوميهما.', + 'tours.stages.dayLost.few': 'مراحل لها يوم لم تعد موجودة: {count}. الحفظ يزيلها من أيامها.', + 'tours.stages.dayLost.many': 'مرحلة لها يوم لم تعد موجودة: {count}. الحفظ يزيلها من أيامها.', + 'tours.stages.unmeasured': 'لم تُقَس بعد', + 'tours.stages.unmeasuredHint': 'لم يُخطَّط مسار هذه المرحلة بعد.', + 'tours.stages.loadFailed': 'تعذّرت قراءة المراحل الآن.', + 'tours.stages.wholeTour': 'الجولة كاملة', + 'tours.stages.totalPartial': 'معروف لـ {known} من {of} مراحل', + 'tours.stages.openInPlanner': 'فتح هذه المرحلة في المخطط', }; export default tours; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index d60eb4c28..878ac11d2 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -1282,5 +1282,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} mərhələ sonu silinir. Marşrut olduğu kimi qalır, Geri al onu qaytarır.', 'tours.stages.toDayConfirm': 'Mərhələ sonlarını sil', 'tours.stages.show': 'Bu mərhələnin harada bitdiyini göstər', + 'tours.stages.fold': 'Mərhələləri gizlət', + 'tours.stages.unfold': 'Mərhələləri göstər', + 'tours.stages.noDay': 'Gün yoxdur', + 'tours.stages.noDayHint': 'Hələ səyahətin heç bir gününə salınmayıb', + 'tours.stages.dayFailed': 'Mərhələni həmin günə salmaq alınmadı.', + 'tours.stages.dayLost': 'Günə salınmış {count} mərhələ artıq yoxdur; saxlama onları günlərindən çıxarır.', + 'tours.stages.dayLost.one': 'Günə salınmış bir mərhələ artıq yoxdur; saxlama onu günündən çıxarır.', + 'tours.stages.unmeasured': 'Hələ ölçülməyib', + 'tours.stages.unmeasuredHint': 'Bu mərhələnin marşrutu hələ planlaşdırılmayıb.', + 'tours.stages.loadFailed': 'Mərhələləri indi oxumaq olmur.', + 'tours.stages.wholeTour': 'Bütün tur', + 'tours.stages.totalPartial': '{of} mərhələdən {known} üçün məlumdur', + 'tours.stages.openInPlanner': 'Bu mərhələni planlayıcıda aç', }; export default tours; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 477cb543e..fb758b676 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -1295,5 +1295,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} fim de etapa é removido. A rota fica como está, e Desfazer o traz de volta.', 'tours.stages.toDayConfirm': 'Remover fins de etapa', 'tours.stages.show': 'Mostrar onde esta etapa termina', + 'tours.stages.fold': 'Ocultar etapas', + 'tours.stages.unfold': 'Mostrar etapas', + 'tours.stages.noDay': 'Sem dia', + 'tours.stages.noDayHint': 'Ainda não está em nenhum dia da viagem', + 'tours.stages.dayFailed': 'Não foi possível colocar a etapa nesse dia.', + 'tours.stages.dayLost': '{count} etapas com dia não existem mais; ao salvar, elas perdem o dia.', + 'tours.stages.dayLost.one': '{count} etapa com dia não existe mais; ao salvar, ela perde o dia.', + 'tours.stages.unmeasured': 'Ainda não medida', + 'tours.stages.unmeasuredHint': 'A rota desta etapa ainda não foi planejada.', + 'tours.stages.loadFailed': 'Não é possível ler as etapas agora.', + 'tours.stages.wholeTour': 'Roteiro completo', + 'tours.stages.totalPartial': 'conhecido para {known} de {of} etapas', + 'tours.stages.openInPlanner': 'Abrir esta etapa no planejador', }; export default tours; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 9837b3113..09abdf6ab 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -1302,5 +1302,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': 'Es treu {count} final d’etapa. La ruta queda com està i Desfés el recupera.', 'tours.stages.toDayConfirm': 'Treu els finals d’etapa', 'tours.stages.show': 'Mostra on acaba aquesta etapa', + 'tours.stages.fold': 'Amaga les etapes', + 'tours.stages.unfold': 'Mostra les etapes', + 'tours.stages.noDay': 'Sense dia', + 'tours.stages.noDayHint': 'Encara no és en cap dia del viatge', + 'tours.stages.dayFailed': 'No s’ha pogut posar l’etapa en aquell dia.', + 'tours.stages.dayLost': '{count} etapes amb dia ja no existeixen; en desar perden el seu dia.', + 'tours.stages.dayLost.one': 'Una etapa amb dia ja no existeix; en desar perd el seu dia.', + 'tours.stages.unmeasured': 'Encara sense mesurar', + 'tours.stages.unmeasuredHint': 'La ruta d’aquesta etapa encara no està planificada.', + 'tours.stages.loadFailed': 'Ara no es poden llegir les etapes.', + 'tours.stages.wholeTour': 'Ruta sencera', + 'tours.stages.totalPartial': 'conegut per a {known} de {of} etapes', + 'tours.stages.openInPlanner': 'Obre aquesta etapa al planificador', }; export default tours; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 27020f263..58f6e8e3d 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -1283,5 +1283,19 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.few': 'Odeberou se {count} konce etap. Trasa zůstane, jak je, a Zpět je vrátí.', 'tours.stages.toDayConfirm': 'Odebrat konce etap', 'tours.stages.show': 'Ukázat, kde etapa končí', + 'tours.stages.fold': 'Skrýt etapy', + 'tours.stages.unfold': 'Zobrazit etapy', + 'tours.stages.noDay': 'Bez dne', + 'tours.stages.noDayHint': 'Zatím není v žádném dni cesty', + 'tours.stages.dayFailed': 'Etapu se nepodařilo zařadit do tohoto dne.', + 'tours.stages.dayLost': '{count} etap se dnem už neexistuje; uložením přijdou o svůj den.', + 'tours.stages.dayLost.one': 'Jedna etapa se dnem už neexistuje; uložením přijde o svůj den.', + 'tours.stages.dayLost.few': '{count} etapy se dnem už neexistují; uložením přijdou o svůj den.', + 'tours.stages.unmeasured': 'Zatím nezměřeno', + 'tours.stages.unmeasuredHint': 'Trasa této etapy ještě není naplánovaná.', + 'tours.stages.loadFailed': 'Etapy teď nelze načíst.', + 'tours.stages.wholeTour': 'Celá trasa', + 'tours.stages.totalPartial': 'známo pro {known} z {of} etap', + 'tours.stages.openInPlanner': 'Otevřít tuto etapu v plánovači', }; export default tours; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 307fa07da..c5f4d8a06 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -1312,6 +1312,19 @@ const tours: TranslationStrings = { '{count} Etappenende wird entfernt. Die Route bleibt, wie sie ist, und Rückgängig holt es zurück.', 'tours.stages.toDayConfirm': 'Etappenenden entfernen', 'tours.stages.show': 'Zeigen, wo diese Etappe endet', + 'tours.stages.fold': 'Etappen ausblenden', + 'tours.stages.unfold': 'Etappen zeigen', + 'tours.stages.noDay': 'Ohne Tag', + 'tours.stages.noDayHint': 'Noch keinem Reisetag zugeordnet', + 'tours.stages.dayFailed': 'Die Etappe konnte diesem Tag nicht zugeordnet werden.', + 'tours.stages.dayLost': '{count} Etappen mit Reisetag gibt es nicht mehr; beim Speichern verlieren sie ihren Tag.', + 'tours.stages.dayLost.one': 'Eine Etappe mit Reisetag gibt es nicht mehr; beim Speichern verliert sie ihren Tag.', + 'tours.stages.unmeasured': 'Noch nicht vermessen', + 'tours.stages.unmeasuredHint': 'Die Route dieser Etappe ist noch nicht geplant.', + 'tours.stages.loadFailed': 'Die Etappen lassen sich gerade nicht lesen.', + 'tours.stages.wholeTour': 'Ganze Tour', + 'tours.stages.totalPartial': 'bekannt für {known} von {of} Etappen', + 'tours.stages.openInPlanner': 'Diese Etappe im Planer öffnen', }; export default tours; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 11f5e1307..97eb05eb8 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -1269,5 +1269,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} stage end is removed. The route stays as it is, and undo brings it back.', 'tours.stages.toDayConfirm': 'Remove stage ends', 'tours.stages.show': 'Show where this stage ends', + 'tours.stages.fold': 'Hide stages', + 'tours.stages.unfold': 'Show stages', + 'tours.stages.noDay': 'No day', + 'tours.stages.noDayHint': 'Not on a day of the trip yet', + 'tours.stages.dayFailed': 'The stage could not be put on that day.', + 'tours.stages.dayLost': '{count} stages on a day no longer exist; saving takes them off their days.', + 'tours.stages.dayLost.one': 'A stage on a day no longer exists; saving takes it off its day.', + 'tours.stages.unmeasured': 'Not measured yet', + 'tours.stages.unmeasuredHint': 'The route of this stage is not planned yet.', + 'tours.stages.loadFailed': 'The stages cannot be read right now.', + 'tours.stages.wholeTour': 'Whole tour', + 'tours.stages.totalPartial': 'known for {known} of {of} stages', + 'tours.stages.openInPlanner': 'Open this stage in the planner', }; export default tours; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index b4eec4cd5..6215aebca 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -1294,5 +1294,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': 'Se quita {count} final de etapa. La ruta queda como está y Deshacer lo recupera.', 'tours.stages.toDayConfirm': 'Quitar finales de etapa', 'tours.stages.show': 'Mostrar dónde termina esta etapa', + 'tours.stages.fold': 'Ocultar etapas', + 'tours.stages.unfold': 'Mostrar etapas', + 'tours.stages.noDay': 'Sin día', + 'tours.stages.noDayHint': 'Aún no está en ningún día del viaje', + 'tours.stages.dayFailed': 'No se pudo poner la etapa en ese día.', + 'tours.stages.dayLost': '{count} etapas con día ya no existen; al guardar pierden su día.', + 'tours.stages.dayLost.one': 'Una etapa con día ya no existe; al guardar pierde su día.', + 'tours.stages.unmeasured': 'Aún sin medir', + 'tours.stages.unmeasuredHint': 'La ruta de esta etapa aún no está planificada.', + 'tours.stages.loadFailed': 'Ahora no se pueden leer las etapas.', + 'tours.stages.wholeTour': 'Ruta completa', + 'tours.stages.totalPartial': 'conocido para {known} de {of} etapas', + 'tours.stages.openInPlanner': 'Abrir esta etapa en el planificador', }; export default tours; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 99b317062..e542c4c9b 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -1278,5 +1278,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} etapi lõpp eemaldatakse. Marsruut jääb samaks ja Võta tagasi taastab selle.', 'tours.stages.toDayConfirm': 'Eemalda etapi lõpud', 'tours.stages.show': 'Näita, kus see etapp lõpeb', + 'tours.stages.fold': 'Peida etapid', + 'tours.stages.unfold': 'Näita etappe', + 'tours.stages.noDay': 'Päevata', + 'tours.stages.noDayHint': 'Pole veel ühelgi reisipäeval', + 'tours.stages.dayFailed': 'Etappi ei õnnestunud sellele päevale panna.', + 'tours.stages.dayLost': '{count} päevaga etappi pole enam olemas; salvestamisel kaotavad need oma päeva.', + 'tours.stages.dayLost.one': 'Üks päevaga etapp pole enam olemas; salvestamisel kaotab see oma päeva.', + 'tours.stages.unmeasured': 'Veel mõõtmata', + 'tours.stages.unmeasuredHint': 'Selle etapi marsruut pole veel planeeritud.', + 'tours.stages.loadFailed': 'Etappe ei saa praegu lugeda.', + 'tours.stages.wholeTour': 'Kogu matk', + 'tours.stages.totalPartial': 'teada {known} etapi kohta {of}-st', + 'tours.stages.openInPlanner': 'Ava see etapp planeerijas', }; export default tours; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 1f941cb7f..444670fda 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -1319,5 +1319,20 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} fin d’étape est retirée. L’itinéraire reste tel quel, et Annuler la rétablit.', 'tours.stages.toDayConfirm': 'Retirer les fins d’étape', 'tours.stages.show': 'Montrer où finit cette étape', + 'tours.stages.fold': 'Masquer les étapes', + 'tours.stages.unfold': 'Afficher les étapes', + 'tours.stages.noDay': 'Sans jour', + 'tours.stages.noDayHint': 'Pas encore sur un jour du voyage', + 'tours.stages.dayFailed': 'L’étape n’a pas pu être placée sur ce jour.', + 'tours.stages.dayLost': + '{count} étapes placées sur un jour n’existent plus ; l’enregistrement les retire de leur jour.', + 'tours.stages.dayLost.one': + '{count} étape placée sur un jour n’existe plus ; l’enregistrement la retire de son jour.', + 'tours.stages.unmeasured': 'Pas encore mesurée', + 'tours.stages.unmeasuredHint': 'L’itinéraire de cette étape n’est pas encore planifié.', + 'tours.stages.loadFailed': 'Les étapes ne peuvent pas être lues pour le moment.', + 'tours.stages.wholeTour': 'Itinéraire complet', + 'tours.stages.totalPartial': 'connu pour {known} étapes sur {of}', + 'tours.stages.openInPlanner': 'Ouvrir cette étape dans le planificateur', }; export default tours; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 65e9e8832..b07ea7863 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -1322,5 +1322,18 @@ const tours: TranslationStrings = { 'Αφαιρείται {count} τέλος σκέλους. Η διαδρομή μένει ως έχει και η Αναίρεση το επαναφέρει.', 'tours.stages.toDayConfirm': 'Αφαίρεση τελών σκελών', 'tours.stages.show': 'Εμφάνιση του σημείου όπου τελειώνει το σκέλος', + 'tours.stages.fold': 'Απόκρυψη σκελών', + 'tours.stages.unfold': 'Εμφάνιση σκελών', + 'tours.stages.noDay': 'Χωρίς ημέρα', + 'tours.stages.noDayHint': 'Δεν έχει ακόμη ημέρα του ταξιδιού', + 'tours.stages.dayFailed': 'Το σκέλος δεν μπόρεσε να μπει σε αυτή την ημέρα.', + 'tours.stages.dayLost': '{count} σκέλη με ημέρα δεν υπάρχουν πια· με την αποθήκευση χάνουν την ημέρα τους.', + 'tours.stages.dayLost.one': 'Ένα σκέλος με ημέρα δεν υπάρχει πια· με την αποθήκευση χάνει την ημέρα του.', + 'tours.stages.unmeasured': 'Δεν έχει μετρηθεί ακόμη', + 'tours.stages.unmeasuredHint': 'Η διαδρομή αυτού του σκέλους δεν έχει σχεδιαστεί ακόμη.', + 'tours.stages.loadFailed': 'Τα σκέλη δεν μπορούν να διαβαστούν αυτή τη στιγμή.', + 'tours.stages.wholeTour': 'Όλη η διαδρομή', + 'tours.stages.totalPartial': 'γνωστό για {known} από {of} σκέλη', + 'tours.stages.openInPlanner': 'Άνοιγμα αυτού του σκέλους στον σχεδιαστή', }; export default tours; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 251a23090..1af8e55f2 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -1300,5 +1300,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} szakaszvég törlődik. Az útvonal változatlan marad, a Visszavonás visszahozza.', 'tours.stages.toDayConfirm': 'Szakaszvégek eltávolítása', 'tours.stages.show': 'Mutasd, hol ér véget a szakasz', + 'tours.stages.fold': 'Szakaszok elrejtése', + 'tours.stages.unfold': 'Szakaszok mutatása', + 'tours.stages.noDay': 'Nincs nap', + 'tours.stages.noDayHint': 'Még nincs az utazás egyik napján sem', + 'tours.stages.dayFailed': 'A szakaszt nem sikerült erre a napra tenni.', + 'tours.stages.dayLost': '{count} nappal rendelkező szakasz már nem létezik; mentéskor elvesztik a napjukat.', + 'tours.stages.dayLost.one': 'Egy nappal rendelkező szakasz már nem létezik; mentéskor elveszti a napját.', + 'tours.stages.unmeasured': 'Még nincs lemérve', + 'tours.stages.unmeasuredHint': 'A szakasz útvonala még nincs megtervezve.', + 'tours.stages.loadFailed': 'A szakaszok most nem olvashatók.', + 'tours.stages.wholeTour': 'Teljes túra', + 'tours.stages.totalPartial': 'ismert {known} szakasznál a {of} közül', + 'tours.stages.openInPlanner': 'A szakasz megnyitása a tervezőben', }; export default tours; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index d86f0a17e..bff3ad312 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -1277,5 +1277,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': '{count} akhir etape dihapus. Rute tetap seperti semula, dan Urungkan mengembalikannya.', 'tours.stages.toDayConfirm': 'Hapus semua akhir etape', 'tours.stages.show': 'Tunjukkan tempat etape ini berakhir', + 'tours.stages.fold': 'Sembunyikan etape', + 'tours.stages.unfold': 'Tampilkan etape', + 'tours.stages.noDay': 'Tanpa hari', + 'tours.stages.noDayHint': 'Belum masuk hari perjalanan mana pun', + 'tours.stages.dayFailed': 'Etape tidak dapat dimasukkan ke hari itu.', + 'tours.stages.dayLost': + '{count} etape yang punya hari sudah tidak ada; saat disimpan, etape itu dilepas dari harinya.', + 'tours.stages.unmeasured': 'Belum diukur', + 'tours.stages.unmeasuredHint': 'Rute etape ini belum direncanakan.', + 'tours.stages.loadFailed': 'Etape tidak dapat dibaca saat ini.', + 'tours.stages.wholeTour': 'Seluruh tur', + 'tours.stages.totalPartial': 'diketahui untuk {known} dari {of} etape', + 'tours.stages.openInPlanner': 'Buka etape ini di perencana', }; export default tours; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 02e7c240c..0e26cbca5 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -1308,5 +1308,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} fine tappa viene tolta. Il percorso resta com’è e Annulla la ripristina.', 'tours.stages.toDayConfirm': 'Togli le fini tappa', 'tours.stages.show': 'Mostra dove finisce questa tappa', + 'tours.stages.fold': 'Nascondi tappe', + 'tours.stages.unfold': 'Mostra tappe', + 'tours.stages.noDay': 'Senza giorno', + 'tours.stages.noDayHint': 'Non ancora in un giorno del viaggio', + 'tours.stages.dayFailed': 'Non è stato possibile mettere la tappa in quel giorno.', + 'tours.stages.dayLost': '{count} tappe assegnate a un giorno non esistono più; salvando perdono il loro giorno.', + 'tours.stages.dayLost.one': 'Una tappa assegnata a un giorno non esiste più; salvando perde il suo giorno.', + 'tours.stages.unmeasured': 'Non ancora misurata', + 'tours.stages.unmeasuredHint': 'Il percorso di questa tappa non è ancora pianificato.', + 'tours.stages.loadFailed': 'Al momento non è possibile leggere le tappe.', + 'tours.stages.wholeTour': 'Tour completo', + 'tours.stages.totalPartial': 'noto per {known} tappe su {of}', + 'tours.stages.openInPlanner': 'Apri questa tappa nel pianificatore', }; export default tours; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 3d69f0a1a..d384b61c1 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -1240,5 +1240,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': '{count} 件のステージ終点を外します。ルートはそのままで、元に戻すで復元できます。', 'tours.stages.toDayConfirm': 'ステージの終点をすべて外す', 'tours.stages.show': 'このステージの終点を表示', + 'tours.stages.fold': 'ステージを隠す', + 'tours.stages.unfold': 'ステージを表示', + 'tours.stages.noDay': '日なし', + 'tours.stages.noDayHint': 'まだ旅行のどの日にも入っていません', + 'tours.stages.dayFailed': 'このステージをその日に入れられませんでした。', + 'tours.stages.dayLost': '日に入っている {count} 件のステージはもう存在しません。保存するとその日から外れます。', + 'tours.stages.unmeasured': 'まだ計測されていません', + 'tours.stages.unmeasuredHint': 'このステージのルートはまだ計画されていません。', + 'tours.stages.loadFailed': '現在ステージを読み込めません。', + 'tours.stages.wholeTour': 'ツアー全体', + 'tours.stages.totalPartial': '{of} 件中 {known} 件のステージで判明', + 'tours.stages.openInPlanner': 'このステージをプランナーで開く', }; export default tours; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 5d03bb6ac..e2ecaaa3c 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -1238,5 +1238,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': '구간 끝 {count}개를 제거합니다. 경로는 그대로이며 실행 취소로 되돌릴 수 있습니다.', 'tours.stages.toDayConfirm': '구간 끝 모두 제거', 'tours.stages.show': '이 구간이 끝나는 곳 보기', + 'tours.stages.fold': '구간 숨기기', + 'tours.stages.unfold': '구간 보기', + 'tours.stages.noDay': '날짜 없음', + 'tours.stages.noDayHint': '아직 여행의 어느 날에도 넣지 않음', + 'tours.stages.dayFailed': '이 구간을 그날에 넣지 못했습니다.', + 'tours.stages.dayLost': '날짜가 있는 구간 {count}개가 더 이상 없습니다. 저장하면 해당 날짜에서 빠집니다.', + 'tours.stages.unmeasured': '아직 측정되지 않음', + 'tours.stages.unmeasuredHint': '이 구간의 경로는 아직 계획되지 않았습니다.', + 'tours.stages.loadFailed': '지금은 구간을 읽을 수 없습니다.', + 'tours.stages.wholeTour': '전체 투어', + 'tours.stages.totalPartial': '{of}개 구간 중 {known}개에서 알려짐', + 'tours.stages.openInPlanner': '이 구간을 플래너에서 열기', }; export default tours; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 907c67bae..35afb7b71 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -1304,5 +1304,18 @@ const tours: TranslationStrings = { '{count} etappe-einde wordt verwijderd. De route blijft zoals hij is en Ongedaan maken zet het terug.', 'tours.stages.toDayConfirm': 'Etappe-eindes verwijderen', 'tours.stages.show': 'Toon waar deze etappe eindigt', + 'tours.stages.fold': 'Etappes verbergen', + 'tours.stages.unfold': 'Etappes tonen', + 'tours.stages.noDay': 'Geen dag', + 'tours.stages.noDayHint': 'Nog niet op een reisdag', + 'tours.stages.dayFailed': 'De etappe kon niet op die dag worden gezet.', + 'tours.stages.dayLost': '{count} etappes met een dag bestaan niet meer; bij opslaan verliezen ze hun dag.', + 'tours.stages.dayLost.one': 'Een etappe met een dag bestaat niet meer; bij opslaan verliest ze haar dag.', + 'tours.stages.unmeasured': 'Nog niet gemeten', + 'tours.stages.unmeasuredHint': 'De route van deze etappe is nog niet gepland.', + 'tours.stages.loadFailed': 'De etappes kunnen nu niet worden gelezen.', + 'tours.stages.wholeTour': 'Hele tocht', + 'tours.stages.totalPartial': 'bekend voor {known} van {of} etappes', + 'tours.stages.openInPlanner': 'Deze etappe openen in de planner', }; export default tours; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index f2a3f3cf7..54624f7c4 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -1328,5 +1328,20 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.many': 'Usuwamy {count} końców etapów. Trasa zostaje bez zmian, a Cofnij je przywróci.', 'tours.stages.toDayConfirm': 'Usuń końce etapów', 'tours.stages.show': 'Pokaż, gdzie kończy się ten etap', + 'tours.stages.fold': 'Ukryj etapy', + 'tours.stages.unfold': 'Pokaż etapy', + 'tours.stages.noDay': 'Bez dnia', + 'tours.stages.noDayHint': 'Jeszcze nie przypisano do dnia podróży', + 'tours.stages.dayFailed': 'Nie udało się przypisać etapu do tego dnia.', + 'tours.stages.dayLost': '{count} etapu z przypisanym dniem już nie istnieje; zapis zdejmie je z ich dni.', + 'tours.stages.dayLost.one': 'Jednego etapu z przypisanym dniem już nie ma; zapis zdejmie go z jego dnia.', + 'tours.stages.dayLost.few': '{count} etapy z przypisanym dniem już nie istnieją; zapis zdejmie je z ich dni.', + 'tours.stages.dayLost.many': '{count} etapów z przypisanym dniem już nie istnieje; zapis zdejmie je z ich dni.', + 'tours.stages.unmeasured': 'Jeszcze niezmierzony', + 'tours.stages.unmeasuredHint': 'Trasa tego etapu nie jest jeszcze zaplanowana.', + 'tours.stages.loadFailed': 'Nie można teraz odczytać etapów.', + 'tours.stages.wholeTour': 'Cała trasa', + 'tours.stages.totalPartial': 'znane dla {known} z {of} etapów', + 'tours.stages.openInPlanner': 'Otwórz ten etap w planerze', }; export default tours; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 924a3a368..e5c929e36 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -1335,5 +1335,20 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.many': 'Будут убраны {count} концов этапов. Маршрут останется прежним, а Отменить вернёт их.', 'tours.stages.toDayConfirm': 'Убрать концы этапов', 'tours.stages.show': 'Показать, где заканчивается этап', + 'tours.stages.fold': 'Скрыть этапы', + 'tours.stages.unfold': 'Показать этапы', + 'tours.stages.noDay': 'Без дня', + 'tours.stages.noDayHint': 'Пока не назначен на день поездки', + 'tours.stages.dayFailed': 'Не удалось назначить этап на этот день.', + 'tours.stages.dayLost': '{count} этапа с днём больше нет; при сохранении они лишатся своих дней.', + 'tours.stages.dayLost.one': '{count} этап с днём больше не существует; при сохранении он лишится своего дня.', + 'tours.stages.dayLost.few': '{count} этапа с днём больше не существуют; при сохранении они лишатся своих дней.', + 'tours.stages.dayLost.many': '{count} этапов с днём больше не существуют; при сохранении они лишатся своих дней.', + 'tours.stages.unmeasured': 'Ещё не измерен', + 'tours.stages.unmeasuredHint': 'Маршрут этого этапа ещё не спланирован.', + 'tours.stages.loadFailed': 'Этапы сейчас не удаётся прочитать.', + 'tours.stages.wholeTour': 'Весь маршрут', + 'tours.stages.totalPartial': 'известно для {known} из {of} этапов', + 'tours.stages.openInPlanner': 'Открыть этот этап в планировщике', }; export default tours; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 3330be45a..79278c0ad 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -1287,5 +1287,19 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.few': 'Odoberú sa {count} konce etáp. Trasa zostane, ako je, a Späť ich vráti.', 'tours.stages.toDayConfirm': 'Odobrať konce etáp', 'tours.stages.show': 'Ukázať, kde etapa končí', + 'tours.stages.fold': 'Skryť etapy', + 'tours.stages.unfold': 'Zobraziť etapy', + 'tours.stages.noDay': 'Bez dňa', + 'tours.stages.noDayHint': 'Zatiaľ nie je v žiadnom dni cesty', + 'tours.stages.dayFailed': 'Etapu sa nepodarilo zaradiť do tohto dňa.', + 'tours.stages.dayLost': '{count} etáp s dňom už neexistuje; uložením prídu o svoj deň.', + 'tours.stages.dayLost.one': 'Jedna etapa s dňom už neexistuje; uložením príde o svoj deň.', + 'tours.stages.dayLost.few': '{count} etapy s dňom už neexistujú; uložením prídu o svoj deň.', + 'tours.stages.unmeasured': 'Zatiaľ nezmerané', + 'tours.stages.unmeasuredHint': 'Trasa tejto etapy ešte nie je naplánovaná.', + 'tours.stages.loadFailed': 'Etapy sa teraz nedajú načítať.', + 'tours.stages.wholeTour': 'Celá trasa', + 'tours.stages.totalPartial': 'známe pre {known} z {of} etáp', + 'tours.stages.openInPlanner': 'Otvoriť túto etapu v plánovači', }; export default tours; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index a36bf6ba0..33ba2a25a 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -1273,5 +1273,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} etappslut tas bort. Rutten förblir som den är, och Ångra tar tillbaka det.', 'tours.stages.toDayConfirm': 'Ta bort etappsluten', 'tours.stages.show': 'Visa var etappen slutar', + 'tours.stages.fold': 'Dölj etapper', + 'tours.stages.unfold': 'Visa etapper', + 'tours.stages.noDay': 'Ingen dag', + 'tours.stages.noDayHint': 'Ännu inte på en dag i resan', + 'tours.stages.dayFailed': 'Etappen kunde inte läggas på den dagen.', + 'tours.stages.dayLost': '{count} etapper med en dag finns inte längre; när du sparar tas de bort från sina dagar.', + 'tours.stages.dayLost.one': 'En etapp med en dag finns inte längre; när du sparar tas den bort från sin dag.', + 'tours.stages.unmeasured': 'Inte uppmätt än', + 'tours.stages.unmeasuredHint': 'Rutten för den här etappen är inte planerad än.', + 'tours.stages.loadFailed': 'Etapperna kan inte läsas just nu.', + 'tours.stages.wholeTour': 'Hela turen', + 'tours.stages.totalPartial': 'känt för {known} av {of} etapper', + 'tours.stages.openInPlanner': 'Öppna etappen i planeraren', }; export default tours; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 38cc23231..e045d2851 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -1222,5 +1222,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': 'จะนำจุดจบช่วง {count} จุดออก เส้นทางยังคงเหมือนเดิม และเลิกทำจะนำกลับมาได้', 'tours.stages.toDayConfirm': 'นำจุดจบช่วงทั้งหมดออก', 'tours.stages.show': 'แสดงตำแหน่งที่ช่วงนี้จบ', + 'tours.stages.fold': 'ซ่อนช่วง', + 'tours.stages.unfold': 'แสดงช่วง', + 'tours.stages.noDay': 'ยังไม่มีวัน', + 'tours.stages.noDayHint': 'ยังไม่ได้อยู่ในวันใดของทริป', + 'tours.stages.dayFailed': 'ไม่สามารถใส่ช่วงนี้ลงในวันนั้นได้', + 'tours.stages.dayLost': 'ช่วงที่มีวันแล้ว {count} ช่วงไม่มีอยู่อีกต่อไป เมื่อบันทึกจะถูกนำออกจากวันของมัน', + 'tours.stages.unmeasured': 'ยังไม่ได้วัด', + 'tours.stages.unmeasuredHint': 'ยังไม่ได้วางแผนเส้นทางของช่วงนี้', + 'tours.stages.loadFailed': 'ไม่สามารถอ่านช่วงได้ในขณะนี้', + 'tours.stages.wholeTour': 'ทั้งเส้นทาง', + 'tours.stages.totalPartial': 'ทราบสำหรับ {known} จาก {of} ช่วง', + 'tours.stages.openInPlanner': 'เปิดช่วงนี้ในตัววางแผน', }; export default tours; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 2899638db..69d9fc8bc 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 1161; +const GENERAL_KEY_COUNT = 1173; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 82055b2f3..e4fb69453 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -1280,5 +1280,18 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.one': '{count} etap sonu kaldırılır. Rota olduğu gibi kalır, Geri al onu geri getirir.', 'tours.stages.toDayConfirm': 'Etap sonlarını kaldır', 'tours.stages.show': 'Bu etabın bittiği yeri göster', + 'tours.stages.fold': 'Etapları gizle', + 'tours.stages.unfold': 'Etapları göster', + 'tours.stages.noDay': 'Gün yok', + 'tours.stages.noDayHint': 'Henüz yolculuğun bir gününde değil', + 'tours.stages.dayFailed': 'Etap o güne konamadı.', + 'tours.stages.dayLost': 'Bir güne atanmış {count} etap artık yok; kaydetmek onları günlerinden çıkarır.', + 'tours.stages.dayLost.one': 'Bir güne atanmış bir etap artık yok; kaydetmek onu gününden çıkarır.', + 'tours.stages.unmeasured': 'Henüz ölçülmedi', + 'tours.stages.unmeasuredHint': 'Bu etabın rotası henüz planlanmadı.', + 'tours.stages.loadFailed': 'Etaplar şu anda okunamıyor.', + 'tours.stages.wholeTour': 'Tüm tur', + 'tours.stages.totalPartial': '{of} etabın {known} tanesi için biliniyor', + 'tours.stages.openInPlanner': 'Bu etabı planlayıcıda aç', }; export default tours; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 63cecc9b3..d707a396a 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -1321,5 +1321,20 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody.many': 'Буде прибрано {count} кінців етапів. Маршрут лишиться як є, а Скасувати поверне їх.', 'tours.stages.toDayConfirm': 'Прибрати кінці етапів', 'tours.stages.show': 'Показати, де закінчується етап', + 'tours.stages.fold': 'Сховати етапи', + 'tours.stages.unfold': 'Показати етапи', + 'tours.stages.noDay': 'Без дня', + 'tours.stages.noDayHint': 'Ще не призначено на день подорожі', + 'tours.stages.dayFailed': 'Не вдалося призначити етап на цей день.', + 'tours.stages.dayLost': '{count} етапу з днем більше немає; під час збереження вони втратять свої дні.', + 'tours.stages.dayLost.one': '{count} етап із днем більше не існує; під час збереження він втратить свій день.', + 'tours.stages.dayLost.few': '{count} етапи з днем більше не існують; під час збереження вони втратять свої дні.', + 'tours.stages.dayLost.many': '{count} етапів з днем більше не існує; під час збереження вони втратять свої дні.', + 'tours.stages.unmeasured': 'Ще не виміряно', + 'tours.stages.unmeasuredHint': 'Маршрут цього етапу ще не сплановано.', + 'tours.stages.loadFailed': 'Етапи зараз не вдається прочитати.', + 'tours.stages.wholeTour': 'Увесь маршрут', + 'tours.stages.totalPartial': 'відомо для {known} з {of} етапів', + 'tours.stages.openInPlanner': 'Відкрити цей етап у планувальнику', }; export default tours; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 753bbbccc..5c498b80f 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -1266,5 +1266,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': 'Bỏ {count} điểm cuối chặng. Tuyến đường giữ nguyên, và Hoàn tác sẽ khôi phục chúng.', 'tours.stages.toDayConfirm': 'Bỏ các điểm cuối chặng', 'tours.stages.show': 'Hiện nơi chặng này kết thúc', + 'tours.stages.fold': 'Ẩn các chặng', + 'tours.stages.unfold': 'Hiện các chặng', + 'tours.stages.noDay': 'Chưa có ngày', + 'tours.stages.noDayHint': 'Chưa được xếp vào ngày nào của chuyến đi', + 'tours.stages.dayFailed': 'Không thể xếp chặng vào ngày đó.', + 'tours.stages.dayLost': '{count} chặng đã có ngày nay không còn; khi lưu, các chặng đó sẽ bị gỡ khỏi ngày.', + 'tours.stages.unmeasured': 'Chưa đo', + 'tours.stages.unmeasuredHint': 'Lộ trình của chặng này chưa được lên kế hoạch.', + 'tours.stages.loadFailed': 'Hiện không thể đọc các chặng.', + 'tours.stages.wholeTour': 'Cả hành trình', + 'tours.stages.totalPartial': 'đã biết cho {known} trên {of} chặng', + 'tours.stages.openInPlanner': 'Mở chặng này trong trình lập kế hoạch', }; export default tours; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 482477bfa..031612f13 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -1181,5 +1181,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': '將移除 {count} 個階段終點。路線保持不變,復原可以還原。', 'tours.stages.toDayConfirm': '移除全部階段終點', 'tours.stages.show': '顯示此階段的終點', + 'tours.stages.fold': '隱藏階段', + 'tours.stages.unfold': '顯示階段', + 'tours.stages.noDay': '未定日期', + 'tours.stages.noDayHint': '尚未安排到行程中的某一天', + 'tours.stages.dayFailed': '無法將此階段安排到那一天。', + 'tours.stages.dayLost': '{count} 個已安排日期的階段已不存在;儲存後將從其日期中移除。', + 'tours.stages.unmeasured': '尚未測量', + 'tours.stages.unmeasuredHint': '此階段的路線尚未規劃。', + 'tours.stages.loadFailed': '暫時無法讀取階段。', + 'tours.stages.wholeTour': '整條路線', + 'tours.stages.totalPartial': '{of} 個階段中 {known} 個已知', + 'tours.stages.openInPlanner': '在規劃器中開啟此階段', }; export default tours; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index a4bc9e8f2..9b0ba7c95 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -1182,5 +1182,17 @@ const tours: TranslationStrings = { 'tours.stages.toDayBody': '将移除 {count} 个阶段终点。路线保持不变,撤销可以恢复。', 'tours.stages.toDayConfirm': '移除全部阶段终点', 'tours.stages.show': '显示此阶段的终点', + 'tours.stages.fold': '隐藏阶段', + 'tours.stages.unfold': '显示阶段', + 'tours.stages.noDay': '未定日期', + 'tours.stages.noDayHint': '尚未安排到行程中的某一天', + 'tours.stages.dayFailed': '无法将该阶段安排到那一天。', + 'tours.stages.dayLost': '{count} 个已安排日期的阶段已不存在;保存后将从其日期中移除。', + 'tours.stages.unmeasured': '尚未测量', + 'tours.stages.unmeasuredHint': '该阶段的路线尚未规划。', + 'tours.stages.loadFailed': '暂时无法读取阶段。', + 'tours.stages.wholeTour': '整条路线', + 'tours.stages.totalPartial': '{of} 个阶段中 {known} 个已知', + 'tours.stages.openInPlanner': '在规划器中打开此阶段', }; export default tours; diff --git a/shared/src/tours/tour-stages.spec.ts b/shared/src/tours/tour-stages.spec.ts index ec01c2444..643a92771 100644 --- a/shared/src/tours/tour-stages.spec.ts +++ b/shared/src/tours/tour-stages.spec.ts @@ -1,4 +1,12 @@ -import { MAX_TOUR_STAGES, maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages'; +import { + MAX_TOUR_STAGES, + maxTourMinutesFor, + TOUR_DAY_MINUTES, + tourStageCountOf, + tourStageIssues, + tourStageKeysOf, + tourStageStopsOf, +} from './tour-stages'; import { MAX_PLANNED_TOUR_DURATION_MINUTES, plannedTimeLimitMessage, @@ -132,4 +140,51 @@ describe('MDT-01 tour stages', () => { expect(tourListItemSchema.parse(item).stage_count).toBeUndefined(); expect(tourListItemSchema.safeParse({ ...item, stage_count: 1 }).success).toBe(false); }); + + it('MDT-SH-009: reads the stops between the stages, keyed, the start through the end', () => { + const keyed = points(5, (i) => ({ key: `stop-${i}xx`, ...(i === 2 ? { stage_end: true } : {}) })); + expect(tourStageStopsOf(keyed)).toEqual([ + { key: 'stop-0xx', lat: 47, lng: 11 }, + { key: 'stop-2xx', lat: 47.02, lng: 11 }, + { key: 'stop-4xx', lat: 47.04, lng: 11 }, + ]); + // A stage is known by the stop it starts at: the end starts none. + expect(tourStageKeysOf(keyed)).toEqual(['stop-0xx', 'stop-2xx']); + }); + + it('MDT-SH-010: has no stage stops for a day tour, a lone point or a stop no save has keyed', () => { + expect(tourStageStopsOf(points(3, (i) => ({ key: `stop-${i}xx` })))).toEqual([]); + expect(tourStageStopsOf([{ lat: 47, lng: 11, role: 'start', key: 'stop-0xx' }])).toEqual([]); + const unkeyed = points(3, (i) => (i === 1 ? { stage_end: true, key: 'stop-1xx' } : {})); + expect(tourStageStopsOf(unkeyed)).toEqual([]); + expect(tourStageKeysOf(unkeyed)).toEqual([]); + }); + + it('MDT-SH-011: lists where the stages of a multi-day tour start and end, and reads a list cached before it', () => { + const item = { + place_id: 1, + tour_type: 'hike', + distance: 12, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: 2, + name: 'Hut to hut', + planned: false, + caution: false, + }; + const stops = [ + { key: 'stop-0xx', lat: 47, lng: 11 }, + { key: 'stop-1xx', lat: 47.1, lng: 11 }, + { key: 'stop-2xx', lat: 47.2, lng: 11 }, + ]; + expect(tourListItemSchema.parse({ ...item, stage_stops: stops }).stage_stops).toEqual(stops); + expect(tourListItemSchema.parse({ ...item, stage_stops: null }).stage_stops).toBeNull(); + expect(tourListItemSchema.parse(item).stage_stops).toBeUndefined(); + expect(tourListItemSchema.safeParse({ ...item, stage_stops: [stops[0]] }).success).toBe(false); + expect(tourListItemSchema.safeParse({ ...item, stage_stops: [{ ...stops[0], key: '' }, stops[1]] }).success).toBe(false); + }); }); diff --git a/shared/src/tours/tour-stages.ts b/shared/src/tours/tour-stages.ts index 7efe513a2..eb949bd72 100644 --- a/shared/src/tours/tour-stages.ts +++ b/shared/src/tours/tour-stages.ts @@ -55,3 +55,42 @@ export function tourStageIssues(points: readonly StagePoint[]): StageIssue[] { issues.push({ path: ['waypoints'], message: `a tour has at most ${MAX_TOUR_STAGES} stages` }); return issues; } + +/** MDT-02: a stop a stage starts or ends at, as a list or a day plan places the stages. */ +export interface TourStageStop { + /** The point's own key, kept across saves; a stage is known by the key of the stop it starts at. */ + key: string; + lat: number; + lng: number; +} + +/** A saved point as the stops between the stages are read from it. */ +interface StoredStagePoint extends StagePoint { + key?: string; + lat: number; + lng: number; +} + +/** + * MDT-02: the stops the stages of a multi-day tour run between, in route + * order: the start, every stop that ends a stage, and the end, so stage `n` + * runs from stop `n - 1` to stop `n`. A stage is put on a day by the key of + * the stop it starts at, which a save keeps, so a stage end set or taken away + * elsewhere leaves the other stages on their days. None for a day tour, and + * none where a stop has no key yet (a point no save has kept), since such a + * stage could not be found again. + */ +export function tourStageStopsOf(points: readonly StoredStagePoint[]): TourStageStop[] { + if (points.length < 2 || tourStageCountOf(points) < 2) return []; + const last = points.length - 1; + const stops = points.filter((point, index) => index === 0 || index === last || point.stage_end === true); + const keyed = stops.flatMap((point) => (point.key ? [{ key: point.key, lat: point.lat, lng: point.lng }] : [])); + return keyed.length === stops.length ? keyed : []; +} + +/** The keys a stage can be put on a day by: those of the stops the stages start at, the last stop starting none. */ +export function tourStageKeysOf(points: readonly StoredStagePoint[]): string[] { + return tourStageStopsOf(points) + .slice(0, -1) + .map((stop) => stop.key); +} diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 57633e2f0..cf296a50f 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -182,6 +182,16 @@ export const tourTypeSchema = z.object({ }); export type TourType = z.infer; +/** CW-03: a station's key, the same across saves. */ +export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/); + +/** MDT-02: a stop a stage of a multi-day tour starts or ends at, by its key and where it is. */ +export const tourStageStopSchema = z.object({ + key: stationKeySchema, + lat: z.number(), + lng: z.number(), +}); + export const tourSchema = z.object({ place_id: z.number().int().positive(), tour_type: tourTypeKeySchema, @@ -312,6 +322,12 @@ export const tourListItemSchema = tourSchema.extend({ * tour. Absent in a list cached before it existed. */ stage_count: z.number().int().min(2).nullable().optional(), + /** + * MDT-02: the stops the stages run between, in route order: the start, every + * stop that ends a stage, and the end. A stage is put on a day by the key of + * the stop it starts at. Null for a day tour; absent in a list cached before it existed. + */ + stage_stops: z.array(tourStageStopSchema).min(3).nullable().optional(), }); export type TourListItem = z.infer; @@ -401,8 +417,6 @@ export const TOUR_STATION_HOURS_MAX = 500; * the server's for a city walk it plans on foot (CW-11). */ export const TOUR_MAX_SNAP_M = 300; -/** CW-03: a station's key, the same across saves. */ -export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/); /** CW-03: how much a station matters, besides normal (null). */ export const STATION_IMPORTANCES = ['optional', 'must'] as const; export const stationImportanceSchema = z.enum(STATION_IMPORTANCES);