From fe78831bd91fed352f1a413c5daf4abc75260d47 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 01:28:50 +0000 Subject: [PATCH] fix(tours): a city walk's walking time, stays and total read apart and alike everywhere, and a stay plans no route again (CW-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stay, a name, an importance or a protection set in a stop's dialog no longer plans a city walk's route again: the walk stays ready to save, its time overview follows at once, and one undo takes the change back without routing either. A stop moved or added plans the route again as before. A walk saves no breaks, so a cafe never counts as a stay and as a break at once, and a break left from another type neither counts nor holds its save up. The planner and the tour detail show a walk's walking time, its stays and their total apart and in the same words: 45 minutes of walking, a museum of 90 minutes and a cafe of 30 minutes make 2 h 45 min. A stay not entered yet makes the stays and the total at least what is known, stays nobody entered read not known yet, and stays entered as 0 read 0 min. A total entered by hand keeps its own label, and the computed total shows beside it. The head of the tour detail, the day row on the desktop and the phone, the tour summary and the library of other trips show that same total, with a ≥ where a stay is missing. Their time on the move is called walking, and the summary says a walk is planned with its stays rather than breaks. The tour list and the library carry the minutes a walk's stops are entered with and how many have no stay yet, counted from the walk's points, so the way back of a loop is no stop and its start's stay counts once. A loop run the other way keeps its start as the stop there, with its key, name and stay. The planner's waypoint list keeps the rows of a leg in a file of their own, and the tour detail its name heading in a part of its own, so both stay within their size limits. --- .../Planner/DayPlanPlaceSubline.tsx | 6 +- .../Planner/DayPlanSidebar.tours.test.tsx | 25 +- .../components/Tours/TourDayRowFacts.test.tsx | 36 +++ .../src/components/Tours/TourDayRowFacts.tsx | 32 +- .../Tours/TourDetailDialog.test.tsx | 27 ++ .../src/components/Tours/TourDetailDialog.tsx | 175 ++++++----- .../src/components/Tours/TourScopeSummary.tsx | 23 +- .../Tours/library/TourLibraryDialog.tsx | 23 +- .../Tours/library/tourLibrary.test.tsx | 27 +- .../Tours/planner/TourPlannerPanels.test.tsx | 22 ++ .../Tours/planner/TourPlannerPanels.tsx | 45 +-- .../Tours/planner/TourWaypointList.tsx | 232 +-------------- .../Tours/planner/WaypointLegRows.tsx | 232 +++++++++++++++ .../Tours/planner/stationModel.test.ts | 68 ++++- .../components/Tours/planner/stationModel.ts | 29 +- .../Tours/planner/tourPlannerFacts.ts | 5 +- .../planner/tourPlannerWaypoints.test.ts | 36 ++- .../Tours/planner/tourPlannerWaypoints.ts | 22 ++ .../Tours/planner/tourRouteActions.test.ts | 22 ++ .../Tours/planner/tourRouteActions.ts | 31 +- .../Tours/planner/useTourPlanner.ts | 45 ++- .../planner/useTourPlanner.walk.test.tsx | 280 ++++++++++++++++++ .../components/Tours/planner/useTourRoute.ts | 20 +- client/src/components/Tours/tourFacts.test.ts | 1 + client/src/components/Tours/tourFacts.ts | 4 +- .../components/Tours/tourPresentation.test.ts | 30 +- .../src/components/Tours/tourPresentation.ts | 49 ++- .../src/components/Tours/tourScope.test.tsx | 34 ++- client/src/components/Tours/tourScope.ts | 13 +- .../Tours/walk/walkTimeFields.test.ts | 101 +++++++ .../components/Tours/walk/walkTimeFields.ts | 64 ++++ .../repositories/TourWaypoints.repository.ts | 13 + server/src/nest/tours/tour-library.service.ts | 13 +- server/src/nest/tours/tours.helpers.ts | 23 +- server/src/nest/tours/tours.service.ts | 22 +- server/tests/e2e/tours-walk.e2e.test.ts | 3 + .../TourWaypoints.repository.test.ts | 29 +- .../unit/nest/tours.walk.service.test.ts | 52 +++- shared/src/i18n/ar/tours.ts | 2 + shared/src/i18n/az/tours.ts | 2 + shared/src/i18n/br/tours.ts | 2 + shared/src/i18n/ca/tours.ts | 2 + shared/src/i18n/cs/tours.ts | 2 + shared/src/i18n/de/tours.ts | 2 + shared/src/i18n/en/tours.ts | 2 + shared/src/i18n/es/tours.ts | 2 + shared/src/i18n/et/tours.ts | 2 + shared/src/i18n/fr/tours.ts | 2 + shared/src/i18n/gr/tours.ts | 2 + shared/src/i18n/hu/tours.ts | 2 + shared/src/i18n/id/tours.ts | 2 + shared/src/i18n/it/tours.ts | 2 + shared/src/i18n/ja/tours.ts | 2 + shared/src/i18n/ko/tours.ts | 2 + shared/src/i18n/nl/tours.ts | 2 + shared/src/i18n/pl/tours.ts | 2 + shared/src/i18n/ru/tours.ts | 2 + shared/src/i18n/sk/tours.ts | 2 + shared/src/i18n/sv/tours.ts | 2 + shared/src/i18n/th/tours.ts | 2 + shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 2 + shared/src/i18n/uk/tours.ts | 2 + shared/src/i18n/vi/tours.ts | 2 + shared/src/i18n/zh-TW/tours.ts | 2 + shared/src/i18n/zh/tours.ts | 2 + shared/src/tours/tour-library.schema.ts | 3 + shared/src/tours/tours.schema.ts | 4 + 68 files changed, 1539 insertions(+), 438 deletions(-) create mode 100644 client/src/components/Tours/planner/WaypointLegRows.tsx create mode 100644 client/src/components/Tours/planner/useTourPlanner.walk.test.tsx create mode 100644 client/src/components/Tours/walk/walkTimeFields.test.ts create mode 100644 client/src/components/Tours/walk/walkTimeFields.ts diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx index 3db2833d8..d2a29f242 100644 --- a/client/src/components/Planner/DayPlanPlaceSubline.tsx +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -8,7 +8,7 @@ import { formatTime } from '../../utils/formatters'; import { fs } from '../shared/DialogShell'; import { TourDayRowPills } from '../Tours/TourDayRowFacts'; import { movingTimeLabel } from '../Tours/tourFacts'; -import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation'; +import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation'; import { WalkProgressPill } from '../Tours/walk/WalkStations'; import { TimePill } from './planParts'; @@ -60,9 +60,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) { - {t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)} + {t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)} {manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''} diff --git a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx index 68f99d6de..0c0e3b4e3 100644 --- a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx @@ -160,7 +160,14 @@ describe('DayPlanSidebar', () => { const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 }); const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }); const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place }); - const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false }; + const times = { + walkingMinutes: 60, + breakMinutes: 35, + plannedTotalMinutes: 95, + manuallyOverridden: false, + walk: null, + atLeast: false, + }; const props = makeDefaultProps({ days: [day], places: [place], @@ -189,6 +196,22 @@ describe('DayPlanSidebar', () => { ); expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override'); + // CW-02: a city walk whose stay is not entered yet plans at least its known total. + view.rerender( + + ); + const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration'); + expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min'); + expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min'); + expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument(); + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] }); view.rerender(); expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull(); diff --git a/client/src/components/Tours/TourDayRowFacts.test.tsx b/client/src/components/Tours/TourDayRowFacts.test.tsx index b8d7b309e..0c2e08fc1 100644 --- a/client/src/components/Tours/TourDayRowFacts.test.tsx +++ b/client/src/components/Tours/TourDayRowFacts.test.tsx @@ -117,6 +117,42 @@ describe('PAD-09 the check bench on a tour row', () => { }); }); +describe("CW-02 a city walk's time on its day row", () => { + const walk: TourListItem = { + ...tour, + tour_type: 'walk', + duration: 45, + break_additional_minutes: 15, + max_hiking_difficulty: null, + station_count: 3, + station_stay_minutes: 120, + station_stays_unknown: 0, + }; + + it('shows the walking time and the stays as the planned total, its breaks left out', () => { + render(); + const time = screen.getByTestId('trip-plan-tour-time'); + expect(time).toHaveTextContent('2 h 45 min'); + expect(time).toHaveAccessibleName('Walking: 45 min, Stays: 2 h, Planned total duration: 2 h 45 min'); + }); + + it('says the total is at least what is known while a stay is not entered', () => { + render(); + const time = screen.getByTestId('trip-plan-tour-time'); + expect(time).toHaveTextContent('≥ 2 h 15 min'); + expect(time).toHaveAccessibleName( + 'Walking: 45 min, Stays: at least 1 h 30 min, Planned total duration: at least 2 h 15 min' + ); + }); + + it('shows the same total among the facts of a phone row, and no breaks', () => { + render(); + expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('≥ 2 h 15 min'); + expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking: 45 min'); + expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument(); + }); +}); + describe('the compact facts of a tour list row', () => { const testIds = () => [...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) => diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index 1c5d55a0e..a00199aa1 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -10,7 +10,8 @@ import { Tooltip } from '../shared/Tooltip'; import { paddleCheckFact } from './paddle/paddleCheckModel'; import { tourDayRowIcon } from './tourDayRowIcon'; import { movingTimeLabel } from './tourFacts'; -import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation'; +import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation'; +import { walkStaysText } from './walk/walkTimeFields'; export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) { const { t } = useTranslation(); @@ -163,27 +164,23 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric'; const times = tourPlannedTimes(tour); - const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes; + const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes); + const total = plannedTotalText(times, t); const timeParts = [ - times.walkingMinutes != null - ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}` - : null, + walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null, times.breakMinutes ? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}` : null, - times.plannedTotalMinutes != null - ? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}` + // CW-02: a city walk's stays, which its total adds to the walking time. + times.walk ? `${t('tours.walk.stays')}: ${walkStaysText(times.walk, t)}` : null, + total + ? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}` : null, ].filter((part): part is string => part !== null); const check = paddleCheckOf(tour, t); - const time = shownMinutes != null && ( - + const shown = plannedTotalText(times, t, true) ?? walking; + const time = shown != null && ( + ); const distance = tour.distance != null && ( { 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') }) @@ -142,6 +143,32 @@ describe('TourDetailDialog', () => { expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument() }) + it('CW-02: shows a city walk its walking time, its stays and their total, and no breaks left from another type', async () => { + const walk = { + ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 45, break_additional_minutes: 15, + station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0, + } + vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [] }) + const view = render() + + // 45 min of walking, a museum of 90 min and a cafe of 30 min, as the day row says it too. + expect(screen.getByLabelText('Walking: 45 min, Stays: 2 h, Planned: 2 h 45 min')).toHaveTextContent('2 h 45 min') + expect(screen.getByText('Stays')).toBeInTheDocument() + expect(screen.queryByText('Breaks')).not.toBeInTheDocument() + + view.rerender() + expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min')) + .toHaveTextContent('≥ 2 h 15 min') + + view.rerender() + expect(screen.getByLabelText( + 'Walking: 45 min, Stays: at least 1 h 30 min, Planned (Manual override): 4 h, Computed: at least 2 h 15 min' + )).toHaveTextContent('4 h') + expect(screen.getByText('Computed')).toBeInTheDocument() + expect(await screen.findByTestId('walk-stations')).toBeInTheDocument() + }) + it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => { render() diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 5f52ed2b6..ce5063e50 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -58,13 +58,16 @@ import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } import { formatPlannedTourDuration, hikeSourceBadgeLabel, + plannedTotalText, tourPlannedTimes, tourSource, tourWebsitePresentation, + type TourPlannedTimes, } from './tourPresentation'; import { useTourDayAssignment } from './useTourDayAssignment'; import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'; import { WalkStationsCard } from './walk/WalkStations'; +import { walkTimeFields } from './walk/walkTimeFields'; interface TourDetailDialogProps extends TourPermissionProps { tour: TourListItem; @@ -167,19 +170,28 @@ function placeStart(place: Place): { lat: number; lng: number } | null { type Translate = (key: string, params?: Record) => string; -/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */ +/** + * The parts of the time a tour takes, as fields: walking, breaks, the planned + * total; a city walk its walking time, its stays and their total, in the + * planner's wording (CW-02). + */ function tourTimeFields( - times: ReturnType, + times: TourPlannedTimes, t: Translate, tour: Pick ): TourMetricField[] { + const walking = { label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t) }; + if (times.walk) { + return walkTimeFields({ + walking, + walkingMinutes: times.walkingMinutes, + stays: times.walk, + manualMinutes: times.manuallyOverridden ? times.plannedTotalMinutes : null, + t, + }); + } return [ - times.walkingMinutes != null - ? { - label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t), - value: formatPlannedTourDuration(times.walkingMinutes), - } - : null, + times.walkingMinutes != null ? { ...walking, value: formatPlannedTourDuration(times.walkingMinutes) } : null, times.breakMinutes ? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) } : null, @@ -202,19 +214,23 @@ function TourHeadPills({ tour, trackColor, timeFields, - shownMinutes, + times, metrics, }: { tour: TourListItem; trackColor: string; timeFields: TourMetricField[]; - shownMinutes: number | null; + /** The planned total where there is one, as the day row shows it, otherwise the walking time. */ + times: TourPlannedTimes; /** Measured from the line as it is now, so the head says what the fields below say. */ metrics: { distanceKm: number | null; gain: number | null; loss: number | null }; }) { const { t } = useTranslation(); const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', '); + const shownTime = + plannedTotalText(times, t, true) ?? + (times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes)); const source = tourSource(tour); // The import this tour was planned from, named while it is still in the trip. const derivedFromName = useTripStore((state) => @@ -258,11 +274,11 @@ function TourHeadPills({ )} - {shownMinutes != null && ( + {shownTime != null && ( - {formatPlannedTourDuration(shownMinutes)} + {shownTime} )} @@ -370,6 +386,79 @@ function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps[' return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName }; } +/** The tour's name in the head; `onUpdatePlace` is given only to someone who may rename it, in place. */ +function TourNameHeading({ + place, + onUpdatePlace, +}: { + place: Place; + onUpdatePlace?: TourDetailDialogProps['onUpdatePlace']; +}) { + const { t } = useTranslation(); + const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor( + place, + onUpdatePlace + ); + if (onUpdatePlace && editingName) { + return ( +
+ setNameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveName(); + if (event.key === 'Escape') { + event.preventDefault(); + setNameDraft(place.name); + setEditingName(false); + } + }} + aria-label={t('places.formName')} + className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none" + style={fs(17, 'subtitle')} + /> + + + +
+ ); + } + return ( +
+

+ {place.name} +

+ {onUpdatePlace && ( + + + + )} +
+ ); +} + export default function TourDetailDialog({ tour, place, @@ -406,10 +495,6 @@ export default function TourDetailDialog({ const [colorOpen, setColorOpen] = useState(false); const [navigationOpen, setNavigationOpen] = useState(false); const navigationAnchorRef = useRef(null); - const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor( - place, - canEdit ? onUpdatePlace : undefined - ); useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget); const heightsHidden = heightsUnknown(tour); @@ -454,66 +539,12 @@ export default function TourDetailDialog({
- {canEdit && editingName ? ( -
- setNameDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') void saveName(); - if (event.key === 'Escape') { - event.preventDefault(); - setNameDraft(place.name); - setEditingName(false); - } - }} - aria-label={t('places.formName')} - className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none" - style={fs(17, 'subtitle')} - /> - - - -
- ) : ( -
-

- {place.name} -

- {canEdit && onUpdatePlace && ( - - - - )} -
- )} + [1], t: Translate): ScopeBadge[] { @@ -92,7 +98,12 @@ function typeBadges(scope: TourTypeScope, unit: Parameters) => string; @@ -30,14 +30,17 @@ const ALL = 'all'; /** A tour's own measures as badges; one it does not have is left out, never shown as zero. */ function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) { - const minutes = tourPlannedTimes(tour).plannedTotalMinutes; + const times = tourPlannedTimes(tour); + const total = plannedTotalText(times, t); + const badge = plannedTotalText(times, t, true); return [ - minutes != null && { - key: 'time', - icon: Clock, - value: formatPlannedTourDuration(minutes), - label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`, - }, + total != null && + badge != null && { + key: 'time', + icon: Clock, + value: badge, + label: `${t('tours.planner.plannedTotalDuration')}: ${total}`, + }, tour.distance != null && { key: 'distance', icon: Ruler, @@ -180,13 +183,13 @@ function ListFilters({ library }: { library: TourLibrary }) { /** The facts of the chosen tour as the preview shows them, each only where the tour has it. */ function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] { - const minutes = tourPlannedTimes(tour).plannedTotalMinutes; + const total = plannedTotalText(tourPlannedTimes(tour), t); const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null); return [ fact(true, t('tours.library.fromTrip'), () => tour.trip.title), fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)), fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)), - fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)), + fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''), fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () => formatElevation(tour.elevation_gain ?? 0, unit) ), diff --git a/client/src/components/Tours/library/tourLibrary.test.tsx b/client/src/components/Tours/library/tourLibrary.test.tsx index a6a8864d0..cc22ce1f0 100644 --- a/client/src/components/Tours/library/tourLibrary.test.tsx +++ b/client/src/components/Tours/library/tourLibrary.test.tsx @@ -7,7 +7,7 @@ import { tourLibraryRepo } from '../../../repo/tourLibraryRepo'; import { TourLibraryDialog } from './TourLibraryDialog'; import { useTourLibrary } from './useTourLibrary'; -// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008 +// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-009 vi.mock('../../Map/MapViewAuto', () => ({ MapViewAuto: ({ route }: { route: Array> }) => ( @@ -189,6 +189,31 @@ describe('TourLibraryDialog', () => { expect(screen.queryByTestId('tour-library-preview')).toBeNull(); }); + it('FE-TOUR-LIBRARY-009: plans a city walk with its stays, at least that much while a stay is not entered (CW-02)', async () => { + const walk = item({ + place_id: 13, + name: 'Old town', + tour_type: 'walk', + duration: 45, + break_additional_minutes: 15, + station_count: 3, + station_stay_minutes: 90, + station_stays_unknown: 1, + }); + list.mockResolvedValue({ ...LIST, tours: [walk] }); + detail.mockResolvedValue({ tour: walk, line: [], waypoint_count: 3 }); + render(); + fireEvent.click(screen.getByTestId('tour-rail-library')); + const row = await screen.findByTestId('tour-library-row-13'); + const time = within(row).getByTestId('tour-library-time'); + expect(time).toHaveTextContent('≥ 2 h 15 min'); + expect(time).toHaveAccessibleName('Planned total duration: at least 2 h 15 min'); + fireEvent.click(row); + expect( + await within(screen.getByTestId('tour-library-preview')).findByText('at least 2 h 15 min') + ).toBeInTheDocument(); + }); + it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => { list.mockResolvedValue({ tours: [], trips: [], truncated: false }); render(); diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 5b4b63692..a2d6ab8c3 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -129,6 +129,28 @@ describe('TourPlannerToursRail', () => { expect(screen.getByText('1 h 20 min')).toBeInTheDocument() }) + it('CW-02: shows a walk its walking time, its stays and their total, a total entered by hand beside the computed one', () => { + const walk = { + mode: { type: 'new-draft' as const }, tourType: 'walk' as const, + walkingDurationMinutes: 45, plannedTotalMinutes: 135, + stationStays: { walkingMinutes: 45, stayMinutes: 90, unknownStays: 1, stations: 3, totalMinutes: 135, partial: true }, + } + const facts = () => screen.getAllByRole('definition').map(value => [value.previousElementSibling?.textContent, value.textContent]) + const view = render() + expect(facts()).toEqual(expect.arrayContaining([ + [expect.stringMatching(/^Est\. walking time/), '45 min'], + ['Stays', 'at least 1 h 30 min'], + ['Planned', 'at least 2 h 15 min'], + ])) + expect(screen.queryByText('Breaks')).not.toBeInTheDocument() + + view.rerender() + expect(facts()).toEqual(expect.arrayContaining([ + ['Planned (Manual override)', '4 h'], + ['Computed', 'at least 2 h 15 min'], + ])) + }) + it('disables saved-tour switching while a Save is pending', () => { const controller = planner({ isSaving: true, diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index cf475f38d..4602245de 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -16,7 +16,7 @@ import { BarButton, SoftPill } from '../../Planner/planParts' import TourListRow from '../TourListRow' import ElevationProfile from '../../shared/ElevationProfile' import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation' -import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' +import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection, type TourMetricField } from '../tourParts' import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts' import { TourDetailsSection, TourNameField } from './TourEditorFields' import { crossesLake } from '../paddle/lakeStyleAdvice' @@ -46,6 +46,7 @@ import { TourScopeSummary } from '../TourScopeSummary' import { useTourListFilters } from '../useTourListFilters' import { TourPoiSearchCard } from '../poi/TourPoiSearchCard' import { BikeWaysCard } from '../bike/BikeWaysCard' +import { walkTimeFields } from '../walk/walkTimeFields' const STATUS_KEY: Partial> = { dirty: 'tours.planner.status.dirty', @@ -643,38 +644,38 @@ function UnreachableNotice({ planner }: { planner: TourPlannerController }) { ) } -/** The time facts of the overview: the estimate (walking, riding or paddling), the breaks and the planned total. */ -function plannerTimeFields(planner: TourPlannerController, t: Translate) { +/** + * The time facts of the overview: the estimate (walking, riding or paddling), + * the breaks and the planned total; a city walk its walking time, its stays and + * their total in the wording the tour detail uses too (CW-02). + */ +function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMetricField[] { const keys = ESTIMATE_KEYS[planner.tourType] - const stays = planner.stationStays + const estimate = { + label: t(keys.labelKey), + shortLabel: t(keys.shortKey), + info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) }, + } + if (planner.stationStays) { + return walkTimeFields({ walking: estimate, walkingMinutes: planner.walkingDurationMinutes, stays: planner.stationStays, + manualMinutes: planner.plannedDurationMinutes, t }) + } return [ { - label: t(keys.labelKey), - shortLabel: t(keys.shortKey), - info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) }, + ...estimate, value: planner.walkingDurationMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.walkingDurationMinutes), }, - stays - ? { - // CW-02: a city walk's stays in place of breaks, the unknown ones named, never counted as nothing. - label: t('tours.walk.stays'), - value: stays.unknownStays > 0 - ? t('tours.walk.totalAtLeast', { time: formatPlannedTourDuration(stays.stayMinutes) }) - : formatPlannedTourDuration(stays.stayMinutes), - } - : { - label: t('tours.planner.breaksShort'), - value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes), - }, + { + label: t('tours.planner.breaksShort'), + value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes), + }, { label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`, value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') - : stays?.partial && planner.plannedDurationMinutes == null - ? t('tours.walk.totalAtLeast', { time: formatPlannedTourDuration(planner.plannedTotalMinutes) }) - : formatPlannedTourDuration(planner.plannedTotalMinutes), + : formatPlannedTourDuration(planner.plannedTotalMinutes), }, ] } diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index 3201bd82d..b54ccb8e2 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -1,10 +1,4 @@ -import { - paddleLayoutOfPoints, - type PaddleLayout, - type PaddleRegionLoadState, - type PaddleRole, - type TourFixedLeg, -} from '@trek/shared'; +import { paddleLayoutOfPoints, type PaddleRole } from '@trek/shared'; import { ArrowDown, ArrowDownUp, @@ -25,37 +19,32 @@ import { } from 'lucide-react'; import { Fragment, useState, type MouseEvent, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; -import { useSettingsStore } from '../../../store/settingsStore'; import { useTourFerrataCrossingStore, type TourFerrataCrossing, type TourWaypointFerrata, } from '../../../store/tourFerrataCrossingStore'; import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore'; -import { formatDistance } from '../../../utils/units'; import { FerrataGlyph } from '../../Ferratas/FerrataGlyph'; import { SoftPill } from '../../Planner/planParts'; import { ContextMenu, useContextMenu } from '../../shared/ContextMenu'; import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; -import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; -import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad'; -import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow'; -import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts'; +import { AutoPointPill } from '../paddle/PaddleLegParts'; +import { PaddleRolePill } from '../paddle/PaddleWaypointParts'; import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; -import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting'; +import { usePortageRouting } from '../paddle/usePortageRouting'; import { RailCard } from '../tourParts'; import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf'; -import type { PaddleLegFailure } from './paddleRouting'; import { StationDialog } from './StationDialog'; import { StationPills } from './stationParts'; -import { lineLengthM } from './tourFixedLegs'; import { TripPlacePicker } from './TripPlacePicker'; import type { TourPlannerController } from './useTourPlanner'; import { useTripPlaceWaypoints } from './useTripPlaceWaypoints'; import { useWaypointPlaceNames } from './useWaypointPlaceNames'; +import { WaypointLegRows } from './WaypointLegRows'; type DropTarget = { id: string; placement: 'before' | 'after' }; @@ -140,37 +129,6 @@ function RouteToolButton({ ); } -/** - * A leg drawn by hand, as a badge on that leg in the dashed look the map draws - * it in: no router planned it, and the badge says so. Its length is the line - * the user drew, the only measure it has. - */ -function FreeLegRow({ leg }: { leg: TourFixedLeg }) { - const { t } = useTranslation(); - const unit = useSettingsStore((state) => state.settings.distance_unit); - // Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two". - const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]); - const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric'); - return ( -
  • - - - - -
  • - ); -} - /** The quiet hint and first-use guidance before a route exists. */ function WaypointEmptyState() { const { t } = useTranslation(); @@ -186,45 +144,6 @@ function WaypointEmptyState() { ); } -/** - * A via ferrata the route climbs between two waypoints, as a badge on that leg: - * the ladder, its name, and its grade and length as chips inside, in the - * ferrata violet the map draws it in. - */ -function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) { - const { t } = useTranslation(); - const unit = useSettingsStore((state) => state.settings.distance_unit); - const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric'); - return ( -
  • - - - - {crossing.name} - - {crossing.grade && ( - - {crossing.grade} - - )} - - {length} - - -
  • - ); -} - /** * A waypoint's number, as the map pins show it. At the foot or the top of a via * ferrata it takes a violet ring and the ferrata's ladder at its corner, named @@ -538,53 +457,6 @@ function WaypointRowActions({ ); } -/** - * What a paddle tour shows under a waypoint: the portage that starts there, - * and the lock at it with its times. - */ -function PaddleRowExtras({ - planner, - point, - index, - layout, - editable, - routing, -}: { - planner: TourPlannerController; - point: TourPlannerController['waypoints'][number]; - index: number; - layout: PaddleLayout; - editable: boolean; - routing: ReturnType; -}) { - const paddle = usePaddleWaypointActions(planner); - const way = usePortageWay(planner.waypoints, routing, index); - const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index); - const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined; - return ( - <> - {point.carryNext && paddle.togglePortage(point.id)} />} - {portage && ( - paddle.setPortageExtra(point.id, minutes)} - way={point.auto ? undefined : way} - /> - )} - {point.lock && ( - paddle.setLock(point.id, next)} - /> - )} - - ); -} - /** * The row under the last waypoint that turns on "set points" for the map the * planner shows, the same switch as the one in the map's layer pill; while it @@ -698,100 +570,6 @@ function WaypointListTools({ planner, isSaving }: { planner: TourPlannerControll ); } -/** - * A leg the router could not plan. Where it lacked the water data of the - * region (PAD-11C), the row follows the data instead: loading or asked for, - * the leg waits calmly; declined, failed or not to be had, it says why. - */ -function LegFailure({ - code, - load, - disabled, - onDrawStraight, - onRetry, - onLoadRetry, -}: { - code: PaddleLegFailure; - load: PaddleRegionLoadState | undefined; - disabled: boolean; - onDrawStraight: () => void; - onRetry: () => void; - onLoadRetry: () => void; -}) { - const { t } = useTranslation(); - if (isRegionLoadPending(load)) { - return ; - } - return ( - - ); -} - -/** - * The rows under a waypoint for the leg that starts there: the via ferrata it - * crosses, a line drawn by hand, and on a paddle tour what the router found on - * the way (PAD-11) or why it could not plan it, plus the portage and lock rows. - */ -function WaypointLegRows({ - planner, - point, - index, - crossings = [], - routedLegs, - paddleLayout, - editable, - portageRouting, - regionLoad, -}: { - planner: TourPlannerController; - point: WaypointPoint; - index: number; - crossings?: TourFerrataCrossing[]; - routedLegs: TourPlannerController['paddleRoute'] | null; - paddleLayout: ReturnType | null; - editable: boolean; - portageRouting: ReturnType; - regionLoad: ReturnType; -}) { - const failure = routedLegs?.failures.find((candidate) => candidate.index === index); - return ( - <> - {crossings.map((crossing) => ( - - ))} - {point.fixedLeg?.kind === 'free' && } - {routedLegs && } - {failure && ( - planner.toggleWaypointFreeLeg(point.id)} - onRetry={() => planner.recomputeLeg(point.id)} - onLoadRetry={regionLoad.retry} - /> - )} - {paddleLayout && ( - - )} - - ); -} - export default function TourWaypointList({ planner, canEdit, diff --git a/client/src/components/Tours/planner/WaypointLegRows.tsx b/client/src/components/Tours/planner/WaypointLegRows.tsx new file mode 100644 index 000000000..734d6f5a4 --- /dev/null +++ b/client/src/components/Tours/planner/WaypointLegRows.tsx @@ -0,0 +1,232 @@ +import type { PaddleLayout, PaddleRegionLoadState, TourFixedLeg } from '@trek/shared'; +import { Spline } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import type { TourFerrataCrossing } from '../../../store/tourFerrataCrossingStore'; +import { formatDistance } from '../../../utils/units'; +import { FerrataGlyph } from '../../Ferratas/FerrataGlyph'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; +import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad'; +import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow'; +import { LockRow, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts'; +import type { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad'; +import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; +import { usePortageWay, type usePortageRouting } from '../paddle/usePortageRouting'; +import type { PaddleLegFailure } from './paddleRouting'; +import { lineLengthM } from './tourFixedLegs'; +import type { TourPlannerController } from './useTourPlanner'; + +type WaypointPoint = TourPlannerController['waypoints'][number]; + +/** + * A leg drawn by hand, as a badge on that leg in the dashed look the map draws + * it in: no router planned it, and the badge says so. Its length is the line + * the user drew, the only measure it has. + */ +function FreeLegRow({ leg }: { leg: TourFixedLeg }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit); + // Read as plain numbers: this workspace compiles without strict mode, where the schema's tuples are "maybe two". + const line = leg.geometry.map(([lat, lng]): [number, number] => [Number(lat), Number(lng)]); + const distance = formatDistance(lineLengthM(line) / 1000, unit ?? 'metric'); + return ( +
  • + + + + +
  • + ); +} + +/** + * A via ferrata the route climbs between two waypoints, as a badge on that leg: + * the ladder, its name, and its grade and length as chips inside, in the + * ferrata violet the map draws it in. + */ +function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit); + const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric'); + return ( +
  • + + + + {crossing.name} + + {crossing.grade && ( + + {crossing.grade} + + )} + + {length} + + +
  • + ); +} + +/** + * What a paddle tour shows under a waypoint: the portage that starts there, + * and the lock at it with its times. + */ +function PaddleRowExtras({ + planner, + point, + index, + layout, + editable, + routing, +}: { + planner: TourPlannerController; + point: TourPlannerController['waypoints'][number]; + index: number; + layout: PaddleLayout; + editable: boolean; + routing: ReturnType; +}) { + const paddle = usePaddleWaypointActions(planner); + const way = usePortageWay(planner.waypoints, routing, index); + const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index); + const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined; + return ( + <> + {point.carryNext && paddle.togglePortage(point.id)} />} + {portage && ( + paddle.setPortageExtra(point.id, minutes)} + way={point.auto ? undefined : way} + /> + )} + {point.lock && ( + paddle.setLock(point.id, next)} + /> + )} + + ); +} + +/** + * A leg the router could not plan. Where it lacked the water data of the + * region (PAD-11C), the row follows the data instead: loading or asked for, + * the leg waits calmly; declined, failed or not to be had, it says why. + */ +function LegFailure({ + code, + load, + disabled, + onDrawStraight, + onRetry, + onLoadRetry, +}: { + code: PaddleLegFailure; + load: PaddleRegionLoadState | undefined; + disabled: boolean; + onDrawStraight: () => void; + onRetry: () => void; + onLoadRetry: () => void; +}) { + const { t } = useTranslation(); + if (isRegionLoadPending(load)) { + return ; + } + return ( + + ); +} + +/** + * The rows under a waypoint for the leg that starts there: the via ferrata it + * crosses, a line drawn by hand, and on a paddle tour what the router found on + * the way (PAD-11) or why it could not plan it, plus the portage and lock rows. + */ +export function WaypointLegRows({ + planner, + point, + index, + crossings = [], + routedLegs, + paddleLayout, + editable, + portageRouting, + regionLoad, +}: { + planner: TourPlannerController; + point: WaypointPoint; + index: number; + crossings?: TourFerrataCrossing[]; + routedLegs: TourPlannerController['paddleRoute'] | null; + paddleLayout: PaddleLayout | null; + editable: boolean; + portageRouting: ReturnType; + regionLoad: ReturnType; +}) { + const failure = routedLegs?.failures.find((candidate) => candidate.index === index); + return ( + <> + {crossings.map((crossing) => ( + + ))} + {point.fixedLeg?.kind === 'free' && } + {routedLegs && } + {failure && ( + planner.toggleWaypointFreeLeg(point.id)} + onRetry={() => planner.recomputeLeg(point.id)} + onLoadRetry={regionLoad.retry} + /> + )} + {paddleLayout && ( + + )} + + ); +} diff --git a/client/src/components/Tours/planner/stationModel.test.ts b/client/src/components/Tours/planner/stationModel.test.ts index ca9fe9cab..f609efded 100644 --- a/client/src/components/Tours/planner/stationModel.test.ts +++ b/client/src/components/Tours/planner/stationModel.test.ts @@ -1,6 +1,6 @@ -// FE-WALK-TIMES-001 to FE-WALK-TIMES-002: a city walk's walking time and stays kept apart (CW-02). +// FE-WALK-TIMES-001 to FE-WALK-TIMES-006: a city walk's walking time and stays kept apart (CW-02). import { describe, expect, it } from 'vitest'; -import { walkTimes } from './stationModel'; +import { parseStay, walkTimes, walkTimesOf, withStationFields } from './stationModel'; import type { TourPlannerWaypoint } from './tourPlannerWaypoints'; const cathedral: TourPlannerWaypoint = { id: 'a', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 30 }; @@ -14,10 +14,11 @@ describe('walkTimes', () => { walkingMinutes: 45, stayMinutes: 30, unknownStays: 1, + stations: 3, totalMinutes: 75, partial: true, }); - expect(walkTimes([cathedral, market], null)).toMatchObject({ totalMinutes: null, partial: false }); + expect(walkTimes([cathedral, market], 20)).toMatchObject({ stations: 2, totalMinutes: 50, partial: false }); }); it("FE-WALK-TIMES-002: the way back of a loop is its start again, so the start's stay counts once", () => { @@ -26,8 +27,69 @@ describe('walkTimes', () => { walkingMinutes: 45, stayMinutes: 30, unknownStays: 1, + stations: 3, totalMinutes: 75, partial: true, }); }); + + it('FE-WALK-TIMES-003: 45 min of walking, a museum of 90 min and a cafe of 30 min are 2 h 45 min', () => { + const museum: TourPlannerWaypoint = { id: 'm', lat: 48.12, lng: 11.52, role: 'via', stayMinutes: 90 }; + const cafe: TourPlannerWaypoint = { id: 'k', lat: 48.13, lng: 11.53, role: 'end', stayMinutes: 30 }; + const start: TourPlannerWaypoint = { id: 's', lat: 48.1, lng: 11.5, role: 'start', stayMinutes: 0 }; + expect(walkTimes([start, museum, cafe], 45)).toMatchObject({ totalMinutes: 165, partial: false, stations: 3 }); + }); + + it('FE-WALK-TIMES-004: without a walking time the stays entered are a part of the whole, and nothing known is no total', () => { + expect(walkTimes([cathedral, market], null)).toMatchObject({ totalMinutes: 30, partial: true }); + expect(walkTimesOf({ walkingMinutes: null, stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual({ + walkingMinutes: null, + stayMinutes: 0, + unknownStays: 2, + stations: 2, + totalMinutes: null, + partial: false, + }); + expect(walkTimesOf({ walkingMinutes: 40, stayMinutes: 0, unknownStays: 0, stations: 2 })).toMatchObject({ + totalMinutes: 40, + partial: false, + }); + }); +}); + +describe('the station dialog model', () => { + it('FE-WALK-TIMES-005: reads a stay as typed: empty is unknown, whole minutes up to a day are taken, the rest is not', () => { + expect(parseStay('')).toBeNull(); + expect(parseStay(' ')).toBeNull(); + expect(parseStay('0')).toBe(0); + expect(parseStay(' 45 ')).toBe(45); + expect(parseStay('1440')).toBe(1440); + for (const text of ['1441', '12.5', '-3', 'abc', '12345']) expect(parseStay(text)).toBeUndefined(); + }); + + it('FE-WALK-TIMES-006: puts the edited fields on the point, each left out where it is empty', () => { + const point: TourPlannerWaypoint = { ...cathedral, key: 'stop-a', openingHours: 'Mo-Su 09:00-18:00' }; + expect( + withStationFields(point, { name: ' Dom ', stayMinutes: 0, importance: 'optional', protected: false }) + ).toEqual({ + id: 'a', + lat: 48.1, + lng: 11.5, + role: 'start', + key: 'stop-a', + openingHours: 'Mo-Su 09:00-18:00', + name: 'Dom', + stayMinutes: 0, + importance: 'optional', + }); + expect(withStationFields(point, { name: ' ', stayMinutes: null, importance: null, protected: true })).toEqual({ + id: 'a', + lat: 48.1, + lng: 11.5, + role: 'start', + key: 'stop-a', + openingHours: 'Mo-Su 09:00-18:00', + protected: true, + }); + }); }); diff --git a/client/src/components/Tours/planner/stationModel.ts b/client/src/components/Tours/planner/stationModel.ts index 1a103b0f0..81f07c78b 100644 --- a/client/src/components/Tours/planner/stationModel.ts +++ b/client/src/components/Tours/planner/stationModel.ts @@ -13,24 +13,35 @@ export interface WalkTimes { stayMinutes: number; /** Stations whose stay nobody entered. */ unknownStays: number; - /** Walking and the known stays; null while the walking time is unknown. */ + /** The walk's stations, so that some stays unknown and all of them unknown read apart. */ + stations: number; + /** Walking and the known stays; the known stays alone while the walking time is not known, null while nothing is. */ totalMinutes: number | null; /** The total leaves out a stay or the walking time that is not known. */ partial: boolean; } +/** The times of a walk from its parts: the walking time, the stays entered, how many are unknown and of how many. */ +export function walkTimesOf(parts: Omit): WalkTimes { + const { walkingMinutes, stayMinutes, unknownStays } = parts; + // Without a walking time the stays entered are still part of the whole: at least that much, never nothing. + const totalMinutes = walkingMinutes !== null ? walkingMinutes + stayMinutes : stayMinutes > 0 ? stayMinutes : null; + return { + ...parts, + totalMinutes, + partial: totalMinutes !== null && (unknownStays > 0 || walkingMinutes === null), + }; +} + export function walkTimes(points: readonly TourPlannerWaypoint[], walkingMinutes: number | null): WalkTimes { // A shaping point is no station, and the way back of a loop is its start again: its stay counts once. const stations = walkStationsOf(points); - const stayMinutes = stations.reduce((sum, station) => sum + (station.stayMinutes ?? 0), 0); - const unknownStays = stations.filter((station) => station.stayMinutes == null).length; - return { + return walkTimesOf({ walkingMinutes, - stayMinutes, - unknownStays, - totalMinutes: walkingMinutes === null ? null : walkingMinutes + stayMinutes, - partial: unknownStays > 0, - }; + stayMinutes: stations.reduce((sum, station) => sum + (station.stayMinutes ?? 0), 0), + unknownStays: stations.filter((station) => station.stayMinutes == null).length, + stations: stations.length, + }); } /** What the station dialog edits; the rest of the point stays as it is. */ diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index e551f147c..560cb6790 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -126,7 +126,8 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { isPaddle, websiteInvalid: input.website.trim().length > 0 && !tourWebsiteSchema.safeParse(input.website).success, plannedDurationInvalid: invalidMinutes(plannedDurationMinutes), - breakAdditionalInvalid: invalidMinutes(breakAdditionalMinutes), + // A city walk sends no breaks (CW-02), so one left from another type cannot hold its save up. + breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes), paddleIncomplete: isPaddle && (boatType === null || waterContext === null), walkingDurationMinutes, automaticPlannedTotalMinutes, @@ -135,6 +136,8 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { plannedDurationMinutes === null && automaticPlannedTotalMinutes !== null && automaticPlannedTotalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES, + // A city walk's stops are its stays: it saves no breaks, so a cafe never counts as a stay and a break at once. + savedBreakAdditionalMinutes: input.tourType === 'walk' ? null : breakAdditionalMinutes, // The router's own time stands in for a hike without heights; a paddle tour has only its speed. savedDurationSeconds: walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60, diff --git a/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts b/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts index f6fcfa443..c2b40f520 100644 --- a/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts +++ b/client/src/components/Tours/planner/tourPlannerWaypoints.test.ts @@ -1,4 +1,4 @@ -// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-009: the editor's waypoint model, shaping points, drawn legs +// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-010: the editor's waypoint model, shaping points, drawn legs // and a city walk's stations with the keys a save answers (P2A-03, PAD-11, CW-01 to CW-04). import type { TourWaypoint } from '@trek/shared'; import { describe, expect, it } from 'vitest'; @@ -6,6 +6,7 @@ import { normalizePlannerWaypoints, plannerWaypointsOf, routeExtrasKey, + routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, @@ -189,4 +190,37 @@ describe('tourPlannerWaypoints', () => { expect(withSavedKeys(points, savedWaypointsOf([A, B, C]))).toBe(points); expect(withSavedKeys(points, [])).toBe(points); }); + + it("FE-TOUR-WAYPOINTS-010: what a route is planned from leaves a station's own fields and its key out, and nothing else", () => { + const stations: Array> = [ + { name: 'Cathedral' }, + { stayMinutes: 45 }, + { stayMinutes: 0 }, + { importance: 'must' }, + { protected: true }, + { openingHours: 'Mo-Su 09:00-18:00' }, + { key: 'stop-b' }, + ]; + for (const edit of stations) expect(routeInputsKey([A, { ...B, ...edit }, C])).toBe(routeInputsKey([A, B, C])); + + const routed: Array> = [ + { lat: 49.011 }, + { lng: 12.011 }, + { shaping: true }, + { lock: { wait_minutes: 10, transit_minutes: null } }, + { + fixedLeg: { + kind: 'free', + geometry: [ + [49.01, 12.01], + [49.02, 12.02], + ], + }, + }, + { linkedPlaceId: 4 }, + { id: 'other' }, + ]; + for (const edit of routed) expect(routeInputsKey([A, { ...B, ...edit }, C])).not.toBe(routeInputsKey([A, B, C])); + expect(routeInputsKey([A, C])).not.toBe(routeInputsKey([A, B, C])); + }); }); diff --git a/client/src/components/Tours/planner/tourPlannerWaypoints.ts b/client/src/components/Tours/planner/tourPlannerWaypoints.ts index f383197e8..08a6e9be8 100644 --- a/client/src/components/Tours/planner/tourPlannerWaypoints.ts +++ b/client/src/components/Tours/planner/tourPlannerWaypoints.ts @@ -142,6 +142,28 @@ export function routeExtrasKey(points: TourPlannerWaypoint[]): string { ); } +/** + * CW-02: everything a route is planned from: every point with all it holds but + * a station's own fields (its name, stay, importance, protection and opening + * hours) and the key a save gave it. A change to those alone leaves the route + * as it is; any other change plans it again. + */ +export function routeInputsKey(points: readonly TourPlannerWaypoint[]): string { + return JSON.stringify( + points.map( + ({ + name: _name, + stayMinutes: _stay, + importance: _importance, + protected: _protected, + openingHours: _hours, + key: _key, + ...routed + }) => routed + ) + ); +} + /** CW-01 to CW-04: what a city walk's station holds besides its spot, none of which the route depends on. */ function stationFieldsKey(points: TourPlannerWaypoint[]): string { return JSON.stringify( diff --git a/client/src/components/Tours/planner/tourRouteActions.test.ts b/client/src/components/Tours/planner/tourRouteActions.test.ts index 9294b0707..89c5301bc 100644 --- a/client/src/components/Tours/planner/tourRouteActions.test.ts +++ b/client/src/components/Tours/planner/tourRouteActions.test.ts @@ -85,6 +85,28 @@ describe('reverseRoute (PR1-02)', () => { expect(isClosedLoop(reversed)).toBe(true); }); + it("keeps a city walk's start station first when its loop runs the other way, and the way back no station", () => { + const start = { ...A, key: 'stop-a', name: 'Cathedral', stayMinutes: 30, importance: 'must', protected: true }; + const loop = closeLoop>( + [start as RoutePoint & Partial, B, C], + 'back' + ); + const reversed = reverseRoute(loop); + expect(reversed.map((point) => point.id)).toEqual(['back', 'c', 'b', 'a']); + expect(reversed[0]).toEqual({ + id: 'back', + lat: A.lat, + lng: A.lng, + key: 'stop-a', + name: 'Cathedral', + stayMinutes: 30, + importance: 'must', + protected: true, + }); + expect(reversed[3]).toEqual({ id: 'a', lat: A.lat, lng: A.lng }); + expect(reverseRoute(reversed)).toEqual(loop); + }); + it('twice is the tour as it was', () => { const route = [{ ...A, fixedLeg: freeLegBetween(A, B) }, { ...B, fixedLeg: FERRATA }, C]; expect(reverseRoute(reverseRoute(route))).toEqual(route); diff --git a/client/src/components/Tours/planner/tourRouteActions.ts b/client/src/components/Tours/planner/tourRouteActions.ts index c48b103e1..c573bd5aa 100644 --- a/client/src/components/Tours/planner/tourRouteActions.ts +++ b/client/src/components/Tours/planner/tourRouteActions.ts @@ -32,6 +32,14 @@ interface StationParts { openingHours?: string | null; } +/** A point split into what makes it a station and the rest of it; a part it does not have is left out. */ +function stationSplit(point: T & StationParts): { station: StationParts; rest: T } { + const { key, name, stayMinutes, importance, protected: kept, openingHours, ...rest } = point; + const parts: StationParts = { key, name, stayMinutes, importance, protected: kept, openingHours }; + const station = Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)); + return { station, rest: rest as T }; +} + /** * A new point at the end of the tour; a loop keeps returning to its start, * so there the point goes in before the way back. @@ -52,19 +60,9 @@ export function withPointAppended(points: readonly T[], newId: string): T[] { - const first: (T & StationParts) | undefined = points[0]; + const first = points[0]; if (!first || points.length < 2 || isClosedLoop(points)) return [...points]; - const { - fixedLeg: _leg, - lock: _lock, - key: _key, - name: _name, - stayMinutes: _stay, - importance: _importance, - protected: _protected, - openingHours: _hours, - ...start - } = first; + const { fixedLeg: _leg, lock: _lock, ...start } = stationSplit(first).rest; return [...points, { ...start, id: newId } as T]; } @@ -72,10 +70,11 @@ export function closeLoop(points: readonly T[], newId: str * The same tour the other way round: the last point starts it, the stops run * backwards, and a fixed leg (a via ferrata, a hand-drawn stretch) stays on the * stretch it covered, its line turned round with it. A loop stays a loop from - * the same spot. + * the same spot, and its start stays the station there: the way back is no + * station, so the start's key, name and stay stay first (CW-02, CW-03). */ export function reverseRoute(points: readonly T[]): T[] { - return points.map((_, index) => { + const reversed = points.map((_, index) => { const point = points[points.length - 1 - index]; // In the new order the leg after this point is the one that led into it before. const before = points[points.length - 2 - index]; @@ -84,6 +83,10 @@ export function reverseRoute(points: readonly T[]): T[] { if (!leg || index === points.length - 1) return rest as T; return { ...rest, fixedLeg: { ...leg, geometry: [...leg.geometry].reverse() } } as T; }); + if (!isClosedLoop(points)) return reversed; + const start = stationSplit(reversed[0]); + const back = stationSplit(reversed[reversed.length - 1]); + return [{ ...start.rest, ...back.station } as T, ...reversed.slice(1, -1), { ...back.rest, ...start.station } as T]; } /** A hand-drawn leg from this point straight to the next. */ diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 50a7f09ce..06e351d59 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -5,9 +5,9 @@ import { tourRepo } from '../../../repo/tourRepo' import { randomId } from '../../../utils/randomId' import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry' import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions' -import { walkTimes } from './stationModel' +import { walkTimes, walkTimesOf } from './stationModel' import { hasCompleteElevation, useTourRoute } from './useTourRoute' -import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints' +import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints' import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts' import { usePlannerHistory } from './usePlannerHistory' import { useTourDraftStorage } from './useTourDraftStorage' @@ -59,6 +59,13 @@ interface HistoryEntry { tourType: PlannableTourType } +/** CW-02: two steps plan the same route: their points alike but for a station's own fields, and the same choices. */ +function sameRoute(a: HistoryEntry, b: HistoryEntry): boolean { + const { waypoints: pointsA, ...choicesA } = a + const { waypoints: pointsB, ...choicesB } = b + return routeInputsKey(pointsA) === routeInputsKey(pointsB) && JSON.stringify(choicesA) === JSON.stringify(choicesB) +} + /** Where a new tour was planned from; an edit leaves it as it was saved. */ function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } { return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {} @@ -233,17 +240,22 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe // A point the router set that the paddler moves or edits is theirs from then on. const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle) if (sameWaypointSnapshot(current, next)) return - invalidateDetailRequest() - interruptRouting() - draftRevisionRef.current += 1 recordStep(snapshot()) waypointsRef.current = next setWaypoints(next) + // CW-02: a station's own fields (its stay, its name) plan no route: the routing goes on, and Save stays as it was. + if (routeInputsKey(current) === routeInputsKey(next)) { + markEdited() + return + } + invalidateDetailRequest() + interruptRouting() + draftRevisionRef.current += 1 setHasUnsavedChanges(true) setSaveOutcome(null) setStatus('dirty') setError(null) - }, [recordStep, invalidateDetailRequest, interruptRouting, snapshot]) + }, [recordStep, invalidateDetailRequest, interruptRouting, markEdited, snapshot]) // Detail laid in after the step that asked for it (a portage's way on land), with no step of its own. const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => { @@ -392,8 +404,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe /** A snapshot of the history put back: the route and what it depends on. */ const restoreStep = useCallback((entry: HistoryEntry) => { + // CW-02: a step that changed a station's own fields only comes back without planning the route again. + const routeKept = sameRoute(entry, snapshot()) invalidateDetailRequest() - interruptRouting() + if (!routeKept) interruptRouting() draftRevisionRef.current += 1 waypointsRef.current = entry.waypoints difficultyRef.current = entry.difficulty @@ -407,9 +421,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setTourTypeState(entry.tourType) setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null) setHasUnsavedChanges(true) + if (routeKept) return setStatus('dirty') setError(null) - }, [invalidateDetailRequest, interruptRouting]) + }, [invalidateDetailRequest, interruptRouting, snapshot]) const { undo: undoStep, redo: redoStep } = history const undo = useCallback(() => { @@ -480,15 +495,17 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null, [effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints]) // CW-02: a city walk's stays, what of them is known. - const stationStays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints]) + const stays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints]) const facts = useMemo(() => tourPlannerFacts({ tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes, paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle }, - paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stationStayMinutes: stationStays?.stayMinutes ?? null, + paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stationStayMinutes: stays?.stayMinutes ?? null, }), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid, - plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stationStays, tourType, waterContext, website]) + plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website]) const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes, - plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts + plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields } = facts + // CW-02: and with the walking time, the walk's times as the overview says them. + const stationStays = useMemo(() => stays && walkTimesOf({ ...stays, walkingMinutes: walkingDurationMinutes }), [stays, walkingDurationMinutes]) const returnToNeutral = useCallback(() => { if (saveInFlightRef.current) return @@ -780,7 +797,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe waypoints: savedWaypointsOf(waypoints, tourType), duration_seconds: savedDurationSeconds, planned_duration_minutes: plannedDurationMinutes, - break_additional_minutes: breakAdditionalMinutes, + break_additional_minutes: savedBreakAdditionalMinutes, ...derivedFromField(targetPlaceId, derivedFromPlaceId), ...(expectedRevision !== null ? { expected_revision: expectedRevision } : {}), } as const @@ -831,7 +848,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe setIsSaving(false) } } - }, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, breakAdditionalMinutes, canSave, + }, [adoptRevision, refuseRevision, revisionBaseRef, clearHistory, savedBreakAdditionalMinutes, canSave, derivedFromPlaceId, description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, forgetDraft, tourType, tripId, typeFields, waypoints, website]) diff --git a/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx b/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx new file mode 100644 index 000000000..6cf828686 --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.walk.test.tsx @@ -0,0 +1,280 @@ +import { act, renderHook } from '@testing-library/react'; +import type { TourCreateRequest, TourCreateResponse } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted( + () => ({ + routeWalkingTour: vi.fn(), + routeCityWalk: vi.fn(), + enrichTourElevations: vi.fn(), + createTour: vi.fn(), + detailTour: vi.fn(), + updateTour: vi.fn(), + }) +); + +vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations })); +vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); +vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) })); + +import type { TourPlannerWaypoint } from './tourPlannerWaypoints'; +import { useTourPlanner } from './useTourPlanner'; + +// FE-TOUR-WALK-PLANNER-001 to FE-TOUR-WALK-PLANNER-007: a city walk's stays in the planner, edited without +// planning its route again, and saved without breaks (CW-02). + +/** 45 minutes on foot between the stops. */ +const routed = { + coordinates: [ + [48, 11], + [48.01, 11.02], + ] as [number, number][], + parts: [ + { + coordinates: [ + [48, 11], + [48.01, 11.02], + ] as [number, number][], + free: false, + }, + ], + distanceMeters: 3200, + durationSeconds: 2700, +}; +const enriched = [ + [48, 11, 520], + [48.01, 11.02, 525], +] as [number, number, number][]; + +/** The server's answer to a save: the points in their order, each with a key. */ +const answered = (body: TourCreateRequest): TourCreateResponse => ({ + tour: { + place_id: 42, + name: body.name, + tour_type: 'walk', + distance: 3.2, + elevation_gain: 5, + elevation_loss: 0, + duration: 45, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: false, + caution: false, + revision: 1, + }, + waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })), +}); + +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + }); +} + +/** The value, or a failure saying it is missing instead of a later undefined. */ +function must(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error('expected a value'); + return value; +} + +/** A walk through three stops, named, routed and with its heights: ready to save. */ +async function walkPlanned() { + const hook = renderHook(() => useTourPlanner({ tripId: 31 })); + act(() => hook.result.current.startNewTour()); + for (const [lat, lng] of [ + [48, 11], + [48.01, 11.02], + [48.02, 11], + ]) + act(() => hook.result.current.addWaypoint(lat, lng)); + act(() => hook.result.current.setTourType('walk')); + act(() => hook.result.current.setName('Old town')); + await settle(); + return hook; +} + +type Planner = ReturnType; + +/** The stay of the stop at `index` set as the station dialog sets it. */ +function stayAt(planner: Planner, index: number, stayMinutes: number | null) { + const id = must(planner.waypoints[index]).id; + act(() => + planner.editWaypoints((points: TourPlannerWaypoint[]) => + points.map((point) => (point.id === id ? { ...point, stayMinutes } : point)) + ) + ); +} + +/** The body of the save the planner sent last. */ +const lastSaved = () => createTour.mock.calls[createTour.mock.calls.length - 1]?.[1] as TourCreateRequest; + +describe('useTourPlanner, city walks (CW-02)', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + routeWalkingTour.mockReset().mockResolvedValue(routed); + routeCityWalk.mockReset().mockResolvedValue(routed); + enrichTourElevations.mockReset().mockResolvedValue(enriched); + createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body)); + detailTour.mockReset(); + updateTour.mockReset(); + }); + + afterEach(() => vi.useRealTimers()); + + it('FE-TOUR-WALK-PLANNER-001: a stay changes the time overview at once, and the route is planned once', async () => { + const { result } = await walkPlanned(); + expect(routeCityWalk).toHaveBeenCalledTimes(1); + expect(result.current.walkingDurationMinutes).toBe(45); + expect(result.current.stationStays).toMatchObject({ + stayMinutes: 0, + unknownStays: 3, + stations: 3, + totalMinutes: 45, + }); + + stayAt(result.current, 1, 90); + stayAt(result.current, 2, 30); + stayAt(result.current, 0, 0); + // Nothing waits for a route: the planner stays ready to save after each stay. + expect(result.current.status).toBe('ready'); + expect(result.current.canSave).toBe(true); + expect(result.current.stationStays).toMatchObject({ + walkingMinutes: 45, + stayMinutes: 120, + unknownStays: 0, + totalMinutes: 165, + partial: false, + }); + expect(result.current.plannedTotalMinutes).toBe(165); + + await settle(); + expect(routeCityWalk).toHaveBeenCalledTimes(1); + expect(enrichTourElevations).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('ready'); + expect(result.current.routeCurrent).toBe(true); + }); + + it('FE-TOUR-WALK-PLANNER-002: one undo takes a stay back, still without routing again', async () => { + const { result } = await walkPlanned(); + stayAt(result.current, 1, 90); + expect(result.current.canUndo).toBe(true); + + act(() => result.current.undo()); + expect(result.current.waypoints[1]?.stayMinutes).toBeUndefined(); + expect(result.current.stationStays?.stayMinutes).toBe(0); + expect(result.current.status).toBe('ready'); + expect(result.current.canSave).toBe(true); + act(() => result.current.redo()); + expect(result.current.waypoints[1]?.stayMinutes).toBe(90); + await settle(); + expect(routeCityWalk).toHaveBeenCalledTimes(1); + }); + + it('FE-TOUR-WALK-PLANNER-003: saves the stays and no breaks, so a stay never counts as a break too', async () => { + const { result } = await walkPlanned(); + // A break kept from the hike this draft was, out of range besides: it neither counts nor holds the save up. + act(() => result.current.setBreakAdditionalMinutes(5000)); + expect(result.current.breakAdditionalInvalid).toBe(false); + stayAt(result.current, 1, 90); + stayAt(result.current, 2, 0); + expect(result.current.canSave).toBe(true); + await act(async () => { + await result.current.save(); + }); + const body = lastSaved(); + expect(body.break_additional_minutes).toBeNull(); + expect(body.waypoints.map((point) => point.stay_minutes)).toEqual([undefined, 90, 0]); + expect(body.duration_seconds).toBe(2700); + expect(result.current.status).toBe('saved'); + }); + + it('FE-TOUR-WALK-PLANNER-004: a hike keeps its breaks: they are saved and checked as before', async () => { + const { result } = await walkPlanned(); + act(() => result.current.setTourType('hike')); + await settle(); + act(() => result.current.setBreakAdditionalMinutes(5000)); + expect(result.current.breakAdditionalInvalid).toBe(true); + expect(result.current.canSave).toBe(false); + act(() => result.current.setBreakAdditionalMinutes(20)); + await act(async () => { + await result.current.save(); + }); + expect(lastSaved().break_additional_minutes).toBe(20); + }); + + it('FE-TOUR-WALK-PLANNER-005: moving a stop still plans the route again', async () => { + const { result } = await walkPlanned(); + const stop = must(result.current.waypoints[1]); + act(() => { + void result.current.setWaypointPosition(stop.id, 48.011, 11.021); + }); + expect(result.current.status).toBe('dirty'); + expect(result.current.canSave).toBe(false); + await settle(); + expect(routeCityWalk).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('ready'); + }); + + it('FE-TOUR-WALK-PLANNER-006: a stay set while the route is being planned lets that planning finish', async () => { + let answer: (value: typeof routed) => void = () => undefined; + routeCityWalk.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve; + }) + ); + const hook = renderHook(() => useTourPlanner({ tripId: 31 })); + act(() => hook.result.current.startNewTour()); + act(() => hook.result.current.addWaypoint(48, 11)); + act(() => hook.result.current.addWaypoint(48.01, 11.02)); + act(() => hook.result.current.setTourType('walk')); + act(() => hook.result.current.setName('Old town')); + await settle(); + expect(hook.result.current.status).toBe('routing'); + + stayAt(hook.result.current, 1, 30); + expect(hook.result.current.status).toBe('routing'); + await act(async () => { + answer(routed); + await vi.advanceTimersByTimeAsync(0); + }); + await settle(); + expect(routeCityWalk).toHaveBeenCalledTimes(1); + expect(hook.result.current.status).toBe('ready'); + expect(hook.result.current.canSave).toBe(true); + expect(hook.result.current.plannedTotalMinutes).toBe(75); + }); + + it('FE-TOUR-WALK-PLANNER-007: a stay changed after a save makes the walk ready to save again, its route as it was', async () => { + const { result } = await walkPlanned(); + await act(async () => { + await result.current.save(); + }); + expect(result.current.status).toBe('saved'); + expect(result.current.canSave).toBe(false); + + stayAt(result.current, 2, 30); + expect(result.current.status).toBe('ready'); + expect(result.current.hasUnsavedChanges).toBe(true); + expect(result.current.canSave).toBe(true); + await settle(); + expect(routeCityWalk).toHaveBeenCalledTimes(1); + }); + + it("FE-TOUR-WALK-PLANNER-008: a loop run the other way keeps its start's stay, counted once", async () => { + const { result } = await walkPlanned(); + stayAt(result.current, 0, 20); + act(() => result.current.returnToStart()); + await settle(); + expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3 }); + + act(() => result.current.reverse()); + expect(result.current.isLoop).toBe(true); + expect(result.current.waypoints[0]?.stayMinutes).toBe(20); + expect(result.current.waypoints[3]?.stayMinutes).toBeUndefined(); + expect(result.current.stationStays).toMatchObject({ stayMinutes: 20, stations: 3, unknownStays: 2 }); + }); +}); diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index 01058d300..3706acec3 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -20,7 +20,7 @@ import { randomId } from '../../../utils/randomId'; import { planPaddleStep, type PaddleTourRoute } from './paddleRouting'; import { normalizePlannerWaypoints, - routeExtrasKey, + routeInputsKey, tourWaypointKey, type TourPlannerWaypoint, } from './tourPlannerWaypoints'; @@ -210,16 +210,20 @@ export function useTourRoute(inputs: TourRouteInputs) { // What a route was planned for besides its points: a route of another type or profile is not this one's. const choices = [tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, paddleBoat, paddleLakeStyle]; const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`; - // CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from. - const extrasKey = useMemo(() => routeExtrasKey(waypoints), [waypoints]); - const runKey = `${plannedKey}#${extrasKey}#${retryToken}`; + // CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from, + // so a change to them alone plans nothing again. + const routeInputs = useMemo(() => routeInputsKey(waypoints), [waypoints]); + const runKey = `${plannedKey}#${routeInputs}#${retryToken}`; + // The points as they are when a run starts, their station fields included, read without making them a reason to run. + const currentPoints = useEffectEvent(() => waypoints); // The route shown was planned whole for exactly this run, so a change that leaves it as it is plans nothing again. const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned)); useEffect(() => { - if (waypoints.length < 2) { + const points = currentPoints(); + if (points.length < 2) { interruptRouting(); - setStatus(waypoints.length === 0 ? 'empty' : 'dirty'); + setStatus(points.length === 0 ? 'empty' : 'dirty'); clearRoute(); onCleared(); setError(null); @@ -238,7 +242,7 @@ export function useTourRoute(inputs: TourRouteInputs) { const run: RouteRun = { inputs: { tripId, - waypoints, + waypoints: points, tourType, maxHikingDifficulty, hikingPreferences, @@ -281,12 +285,12 @@ export function useTourRoute(inputs: TourRouteInputs) { patchWaypoints, plannedKey, retryToken, + routeInputs, runKey, setError, setStatus, tourType, tripId, - waypoints, ]); const { forKey, forRun: _run, ...shown } = planned; diff --git a/client/src/components/Tours/tourFacts.test.ts b/client/src/components/Tours/tourFacts.test.ts index 069548405..59ed153a0 100644 --- a/client/src/components/Tours/tourFacts.test.ts +++ b/client/src/components/Tours/tourFacts.test.ts @@ -30,5 +30,6 @@ describe('movingTimeLabel', () => { expect(movingTimeLabel({ tour_type: 'kayak' }, 'walking', t)).toBe('tours.paddle.timeEstimatedShort'); expect(movingTimeLabel({ tour_type: 'hike' }, 'walking', t)).toBe('walking'); expect(movingTimeLabel(undefined, 'walking', t)).toBe('walking'); + expect(movingTimeLabel({ tour_type: 'walk' }, 'walking', t)).toBe('tours.walk.timeEstimatedShort'); }); }); diff --git a/client/src/components/Tours/tourFacts.ts b/client/src/components/Tours/tourFacts.ts index c896edbb0..a528e9d6a 100644 --- a/client/src/components/Tours/tourFacts.ts +++ b/client/src/components/Tours/tourFacts.ts @@ -46,7 +46,8 @@ export function withoutHeights< /** * What the time on the move is called: walking for a hike (in the wording the - * place asks for), riding for a bike tour, paddling for a paddle tour. + * place asks for) and for a city walk (CW-02), riding for a bike tour, + * paddling for a paddle tour. */ export function movingTimeLabel( tour: { tour_type?: string } | undefined, @@ -54,5 +55,6 @@ export function movingTimeLabel( t: (key: string) => string ): string { if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort'); + if (tour?.tour_type === 'walk') return t('tours.walk.timeEstimatedShort'); return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel; } diff --git a/client/src/components/Tours/tourPresentation.test.ts b/client/src/components/Tours/tourPresentation.test.ts index 2611d1367..8188566ca 100644 --- a/client/src/components/Tours/tourPresentation.test.ts +++ b/client/src/components/Tours/tourPresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' +import { filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' describe('tourPresentation', () => { it('formats planned Tour totals using the shared compact duration formatter', () => { @@ -10,14 +10,38 @@ describe('tourPresentation', () => { it('calculates planned total from walking and breaks unless a manual override exists', () => { expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: null, break_additional_minutes: 25 })).toEqual({ - walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, + walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 105, manuallyOverridden: false, walk: null, atLeast: false, }) expect(tourPlannedTimes({ duration: 80, planned_duration_minutes: 130, break_additional_minutes: 25 })).toEqual({ - walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, + walkingMinutes: 80, breakMinutes: 25, plannedTotalMinutes: 130, manuallyOverridden: true, walk: null, atLeast: false, }) expect(tourPlannedTimes({ duration: null, planned_duration_minutes: null, break_additional_minutes: 25 }).plannedTotalMinutes).toBeNull() }) + it('CW-02: adds a city walk its stays and no breaks, and says the total is at least that while a stay is not entered', () => { + const walk = { tour_type: 'walk' as const, duration: 45, planned_duration_minutes: null, break_additional_minutes: 15 } + expect(tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0 })).toEqual({ + walkingMinutes: 45, breakMinutes: null, plannedTotalMinutes: 165, manuallyOverridden: false, atLeast: false, + walk: { walkingMinutes: 45, stayMinutes: 120, unknownStays: 0, stations: 3, totalMinutes: 165, partial: false }, + }) + const partial = tourPlannedTimes({ ...walk, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 }) + expect(partial).toMatchObject({ plannedTotalMinutes: 135, atLeast: true }) + // A total entered by hand is that total, whatever the stays say. + expect(tourPlannedTimes({ ...walk, planned_duration_minutes: 200, station_count: 3, station_stay_minutes: 90, station_stays_unknown: 1 })) + .toMatchObject({ plannedTotalMinutes: 200, manuallyOverridden: true, atLeast: false }) + // A list cached before the stays were listed: the walking time alone, and still no breaks. + expect(tourPlannedTimes(walk)).toMatchObject({ breakMinutes: null, plannedTotalMinutes: 45, walk: null, atLeast: false }) + expect(tourPlannedTimes({ ...walk, station_stay_minutes: 30 }).walk).toMatchObject({ stations: 0, unknownStays: 0, totalMinutes: 75 }) + }) + + it('CW-02: says a planned total as it is, at least that much, or as a badge with ≥', () => { + const t = (key: string, params?: Record) => `${key}:${String(params?.time)}` + expect(plannedTotalText({ plannedTotalMinutes: null, atLeast: false }, t)).toBeNull() + expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: false }, t)).toBe('2 h 45 min') + expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t)).toBe('tours.walk.totalAtLeast:2 h 45 min') + expect(plannedTotalText({ plannedTotalMinutes: 165, atLeast: true }, t, true)).toBe('≥ 2 h 45 min') + }) + it('uses the source abstraction for TREK, GPX import, and wanderer badges', () => { const t = vi.fn((key: string) => key) diff --git a/client/src/components/Tours/tourPresentation.ts b/client/src/components/Tours/tourPresentation.ts index 9f8e9517a..f7edc27d3 100644 --- a/client/src/components/Tours/tourPresentation.ts +++ b/client/src/components/Tours/tourPresentation.ts @@ -1,6 +1,7 @@ import type { TourListItem, TourOrigin } from '@trek/shared' import { tourWebsiteSchema } from '@trek/shared' import { formatDurationShort } from '@trek/shared/roadtrip' +import { walkTimesOf, type WalkTimes } from './planner/stationModel' export type TourSource = TourOrigin @@ -21,20 +22,58 @@ export interface TourPlannedTimes { breakMinutes: number | null plannedTotalMinutes: number | null manuallyOverridden: boolean + /** CW-02: a city walk's walking time and stays as the list knows them; null for any other type, or a list without them. */ + walk: WalkTimes | null + /** The planned total leaves out a stay nobody entered yet: it is at least that much. */ + atLeast: boolean } -export function tourPlannedTimes(tour: Pick): TourPlannedTimes { +type PlannedTimesSource = Pick + & Partial> + +/** CW-02: a city walk's times from what the list says of its stations; null where it says nothing of them. */ +function walkTimesOfTour(tour: PlannedTimesSource): WalkTimes | null { + if (tour.tour_type !== 'walk' || tour.station_stay_minutes == null) return null + return walkTimesOf({ + walkingMinutes: tour.duration, + stayMinutes: tour.station_stay_minutes, + unknownStays: tour.station_stays_unknown ?? 0, + stations: tour.station_count ?? 0, + }) +} + +/** + * The times a tour is planned with. A city walk takes no breaks: its stops are + * its stays, so a break kept from another type never counts on top (CW-02), and + * its total is the walking time and the stays entered. + */ +export function tourPlannedTimes(tour: PlannedTimesSource): TourPlannedTimes { const manualOverride = tour.planned_duration_minutes ?? null - const breaks = tour.break_additional_minutes ?? 0 - const plannedTotal = manualOverride ?? (tour.duration == null ? null : tour.duration + breaks) + const walk = walkTimesOfTour(tour) + const breaks = tour.tour_type === 'walk' ? null : (tour.break_additional_minutes ?? null) + const automatic = walk ? walk.totalMinutes : (tour.duration == null ? null : tour.duration + (breaks ?? 0)) return { walkingMinutes: tour.duration, - breakMinutes: tour.break_additional_minutes ?? null, - plannedTotalMinutes: plannedTotal, + breakMinutes: breaks, + plannedTotalMinutes: manualOverride ?? automatic, manuallyOverridden: manualOverride !== null, + walk, + atLeast: manualOverride === null && !!walk?.partial, } } +/** + * The planned total as said, or null without one: "at least" where a city + * walk's stay is not entered yet (CW-02). A badge says it as "≥", the way a + * sum short of the whole reads in the tour summary. + */ +export function plannedTotalText(times: Pick, t: Translate, badge = false): string | null { + if (times.plannedTotalMinutes == null) return null + const time = formatPlannedTourDuration(times.plannedTotalMinutes) + if (!times.atLeast) return time + return badge ? `≥ ${time}` : t('tours.walk.totalAtLeast', { time }) +} + const TOUR_WEBSITE_PROVIDERS = [ { provider: 'Komoot', domains: ['komoot.com'] }, { provider: 'AllTrails', domains: ['alltrails.com'] }, diff --git a/client/src/components/Tours/tourScope.test.tsx b/client/src/components/Tours/tourScope.test.tsx index 5d4886814..6e2761142 100644 --- a/client/src/components/Tours/tourScope.test.tsx +++ b/client/src/components/Tours/tourScope.test.tsx @@ -90,7 +90,23 @@ describe('tourScopeSummary (STA-01)', () => { expect(hike.routedMinutes).toEqual({ value: 300, known: 2, of: 2 }); expect(hike.importedMinutes).toEqual({ value: 150, known: 1, of: 1 }); // Planned: each tour's own time plus its breaks (HIKE_B's 30 min). - expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3 }); + expect(hike.plannedMinutes).toEqual({ value: 480, known: 3, of: 3, atLeast: false }); + }); + + it('CW-02: plans a city walk with its stays and no breaks, at least that much while a stay is not entered', () => { + const walk = tour({ + place_id: 6, + tour_type: 'walk', + duration: 45, + break_additional_minutes: 15, + station_count: 3, + station_stay_minutes: 120, + station_stays_unknown: 0, + }); + const [entered] = tourScopeSummary([walk]).types; + expect(entered.plannedMinutes).toEqual({ value: 165, known: 1, of: 1, atLeast: false }); + const [open] = tourScopeSummary([walk, { ...walk, place_id: 7, station_stays_unknown: 1 }]).types; + expect(open.plannedMinutes).toEqual({ value: 330, known: 2, of: 2, atLeast: true }); }); it('sums no heights for paddling, not even those a recorded paddle track brought', () => { @@ -128,6 +144,22 @@ describe('TourScopeSummary', () => { expect(screen.getByTestId('tour-scope-kayak-distance')).toHaveTextContent('12 km'); }); + it('CW-02: marks a planned time whose city walk has a stay not entered as at least, and says so', () => { + const walk = tour({ + place_id: 6, + tour_type: 'walk', + duration: 45, + station_count: 3, + station_stay_minutes: 90, + station_stays_unknown: 1, + }); + render(); + fireEvent.click(screen.getByTestId('tour-scope-toggle')); + const planned = screen.getByTestId('tour-scope-walk-planned'); + expect(planned).toHaveTextContent('≥ 2 h 15 min'); + expect(planned).toHaveAccessibleName('Planned with stays: at least 2 h 15 min'); + }); + it('starts folded to its counts, and remembers an opening in this browser', () => { const { unmount } = render(); expect(screen.queryByTestId('tour-scope-hike')).toBeNull(); diff --git a/client/src/components/Tours/tourScope.ts b/client/src/components/Tours/tourScope.ts index 143beea15..80153e64d 100644 --- a/client/src/components/Tours/tourScope.ts +++ b/client/src/components/Tours/tourScope.ts @@ -11,6 +11,8 @@ export interface ScopeSum { value: number | null; known: number; of: number; + /** CW-02: a value counted is itself only what is known of it (a city walk's stay not entered yet): the sum is at least this. */ + atLeast?: boolean; } /** One tour type's share of the trip: how many tours, how many on a day, and their measures. */ @@ -64,7 +66,16 @@ function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[], onDay: O descentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_loss)) : NO_SUM, routedMinutes: sumOf(planned.map((tour) => tour.duration)), importedMinutes: sumOf(imported.map((tour) => tour.duration)), - plannedMinutes: sumOf(tours.map((tour) => tourPlannedTimes(tour).plannedTotalMinutes)), + plannedMinutes: plannedSum(tours), + }; +} + +/** The planned totals summed; at least the sum where a city walk's total leaves out a stay nobody entered (CW-02). */ +function plannedSum(tours: readonly TourListItem[]): ScopeSum { + const times = tours.map((tour) => tourPlannedTimes(tour)); + return { + ...sumOf(times.map((time) => time.plannedTotalMinutes)), + atLeast: times.some((time) => time.atLeast), }; } diff --git a/client/src/components/Tours/walk/walkTimeFields.test.ts b/client/src/components/Tours/walk/walkTimeFields.test.ts new file mode 100644 index 000000000..8b9d05b67 --- /dev/null +++ b/client/src/components/Tours/walk/walkTimeFields.test.ts @@ -0,0 +1,101 @@ +// FE-WALK-TIME-FIELDS-001 to FE-WALK-TIME-FIELDS-005: a city walk's walking time, stays and total in one +// wording for the planner and the tour detail (CW-02). +import { describe, expect, it } from 'vitest'; +import { walkStaysText, walkTimeFields } from './walkTimeFields'; + +const t = (key: string, params?: Record) => + ({ + 'tours.planner.walkingTimeUnknown': 'Not calculated', + 'tours.planner.plannedShort': 'Planned', + 'tours.planner.plannedTotalManual': 'Manual override', + 'tours.walk.stays': 'Stays', + 'tours.walk.station.stayUnknown': 'Not known yet', + 'tours.walk.totalComputed': 'Computed', + })[key] ?? (key === 'tours.walk.totalAtLeast' ? `at least ${String(params?.time)}` : key); + +const walking = { label: 'Walking' }; +const fieldsOf = ( + walkingMinutes: number | null, + stays: { stayMinutes: number; unknownStays: number; stations: number }, + manualMinutes: number | null = null +) => walkTimeFields({ walking, walkingMinutes, stays, manualMinutes, t }).map((field) => [field.label, field.value]); + +describe('walkTimeFields', () => { + it('FE-WALK-TIME-FIELDS-001: 45 min of walking, a museum of 90 min and a cafe of 30 min make 2 h 45 min', () => { + expect(fieldsOf(45, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([ + ['Walking', '45 min'], + ['Stays', '2 h'], + ['Planned', '2 h 45 min'], + ]); + }); + + it('FE-WALK-TIME-FIELDS-002: a stay not entered makes the stays and the total at least what is known', () => { + expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 })).toEqual([ + ['Walking', '45 min'], + ['Stays', 'at least 1 h 30 min'], + ['Planned', 'at least 2 h 15 min'], + ]); + // No stay entered at all is not known, never nothing. + expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 3, stations: 3 })).toEqual([ + ['Walking', '45 min'], + ['Stays', 'Not known yet'], + ['Planned', 'at least 45 min'], + ]); + }); + + it('FE-WALK-TIME-FIELDS-003: stays all entered as 0 are 0 min, passed through on purpose', () => { + expect(fieldsOf(45, { stayMinutes: 0, unknownStays: 0, stations: 2 })).toEqual([ + ['Walking', '45 min'], + ['Stays', '0 min'], + ['Planned', '45 min'], + ]); + }); + + it('FE-WALK-TIME-FIELDS-004: without a walking time the stays entered are a part of the total, and nothing known none', () => { + expect(fieldsOf(null, { stayMinutes: 120, unknownStays: 0, stations: 3 })).toEqual([ + ['Walking', 'Not calculated'], + ['Stays', '2 h'], + ['Planned', 'at least 2 h'], + ]); + expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 2, stations: 2 })).toEqual([ + ['Walking', 'Not calculated'], + ['Stays', 'Not known yet'], + ['Planned', 'Not calculated'], + ]); + }); + + it('FE-WALK-TIME-FIELDS-005: a total entered by hand keeps its label, and the computed one shows beside it', () => { + expect(fieldsOf(45, { stayMinutes: 90, unknownStays: 1, stations: 3 }, 240)).toEqual([ + ['Walking', '45 min'], + ['Stays', 'at least 1 h 30 min'], + ['Planned (Manual override)', '4 h'], + ['Computed', 'at least 2 h 15 min'], + ]); + expect(fieldsOf(null, { stayMinutes: 0, unknownStays: 1, stations: 1 }, 60)[3]).toEqual([ + 'Computed', + 'Not calculated', + ]); + }); + + it('keeps the walking label and its explanation as the planner hands them in', () => { + const [first] = walkTimeFields({ + walking: { label: 'Est. walking time', shortLabel: 'Walking', info: { label: 'How', description: 'Router' } }, + walkingMinutes: 30, + stays: { stayMinutes: 0, unknownStays: 0, stations: 2 }, + manualMinutes: null, + t, + }); + expect(first).toEqual({ + label: 'Est. walking time', + shortLabel: 'Walking', + info: { label: 'How', description: 'Router' }, + value: '30 min', + }); + }); +}); + +describe('walkStaysText', () => { + it('says a walk without stations has no stays to wait for', () => { + expect(walkStaysText({ stayMinutes: 0, unknownStays: 0, stations: 0 }, t)).toBe('0 min'); + }); +}); diff --git a/client/src/components/Tours/walk/walkTimeFields.ts b/client/src/components/Tours/walk/walkTimeFields.ts new file mode 100644 index 000000000..014ed3630 --- /dev/null +++ b/client/src/components/Tours/walk/walkTimeFields.ts @@ -0,0 +1,64 @@ +import { walkTimesOf, type WalkTimes } from '../planner/stationModel'; +import type { TourMetricField } from '../tourParts'; +import { formatPlannedTourDuration, plannedTotalText } from '../tourPresentation'; + +type Translate = (key: string, params?: Record) => string; + +/** What a city walk's stays are known of: how many, how many have no stay entered, and the stays entered. */ +type WalkStays = Pick; + +/** + * A city walk's stays as said: their sum, at least the sum while a stay is not + * entered yet, and not known while none is. A stop passed through counts as + * the 0 entered for it, so stays all entered as 0 read "0 min". + */ +export function walkStaysText(stays: WalkStays, t: Translate): string { + if (stays.stations > 0 && stays.unknownStays === stays.stations) return t('tours.walk.station.stayUnknown'); + const time = formatPlannedTourDuration(stays.stayMinutes); + return stays.unknownStays > 0 ? t('tours.walk.totalAtLeast', { time }) : time; +} + +export interface WalkTimeFieldsInput { + /** How the walking time is named: with its explanation in the planner, short in the tour detail. */ + walking: Omit; + walkingMinutes: number | null; + stays: WalkStays; + /** A total entered by hand, which stays apart from the one worked out; null when there is none. */ + manualMinutes: number | null; + t: Translate; +} + +/** + * CW-02: a city walk's times as fields, in one wording for the planner and the + * tour detail: the walking time, the stays, and the planned total of both. A + * total entered by hand keeps its own label, and the total worked out shows + * beside it as computed, so the two never read as one. 45 min of walking, a + * museum of 90 min and a cafe of 30 min read 45 min, 2 h and 2 h 45 min. + */ +export function walkTimeFields({ + walking, + walkingMinutes, + stays, + manualMinutes, + t, +}: WalkTimeFieldsInput): TourMetricField[] { + const notCalculated = t('tours.planner.walkingTimeUnknown'); + const { stayMinutes, unknownStays, stations } = stays; + const times = walkTimesOf({ walkingMinutes, stayMinutes, unknownStays, stations }); + const computed = + plannedTotalText({ plannedTotalMinutes: times.totalMinutes, atLeast: times.partial }, t) ?? notCalculated; + const parts: TourMetricField[] = [ + { ...walking, value: walkingMinutes == null ? notCalculated : formatPlannedTourDuration(walkingMinutes) }, + { label: t('tours.walk.stays'), value: walkStaysText(stays, t) }, + ]; + const planned = t('tours.planner.plannedShort'); + if (manualMinutes == null) return [...parts, { label: planned, value: computed }]; + return [ + ...parts, + { + label: `${planned} (${t('tours.planner.plannedTotalManual')})`, + value: formatPlannedTourDuration(manualMinutes), + }, + { label: t('tours.walk.totalComputed'), value: computed }, + ]; +} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts index 1bd263450..08fcede11 100644 --- a/server/src/db/repositories/TourWaypoints.repository.ts +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -139,6 +139,19 @@ export class TourWaypointsRepository extends TrekRepository { return rows.map(({ place_id, ...point }) => ({ place_id, ...readWaypoint(point) })); } + /** TW2b: every waypoint of these tours with its place, ordered by place and sequence; none for no tour. */ + async listForPlaces(place_ids: readonly number[]): Promise { + if (place_ids.length === 0) return []; + const rows = await this.db() + .selectFrom('tour_waypoints') + .select(['place_id', ...WAYPOINT_COLUMNS]) + .where('place_id', 'in', [...new Set(place_ids)]) + .orderBy('place_id', 'asc') + .orderBy('sequence', 'asc') + .execute(); + return rows.map(({ place_id, ...point }) => ({ place_id, ...readWaypoint(point) })); + } + /** * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock, shaping) * VALUES (...)` for each point. The caller owns the transaction: a create diff --git a/server/src/nest/tours/tour-library.service.ts b/server/src/nest/tours/tour-library.service.ts index d66897ba4..dd9caa998 100644 --- a/server/src/nest/tours/tour-library.service.ts +++ b/server/src/nest/tours/tour-library.service.ts @@ -1,7 +1,7 @@ import { Tours } from '../../db/entities/Tours.entity'; import type { ToursRepository, TourLibraryRow } from '../../db/repositories/Tours.repository'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; -import { parseRouteGeometry, thinnedLine } from './tours.helpers'; +import { parseRouteGeometry, thinnedLine, walkStationFacts } from './tours.helpers'; import { ToursService } from './tours.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; @@ -21,7 +21,8 @@ import { /** The most rows one list reads before the name is matched; past it the answer says it was cut. */ const LIBRARY_READ_LIMIT = 2000; -function toItem(row: TourLibraryRow): TourLibraryItem { +/** A tour of another trip as the list shows it; a city walk with its stations and their stays (CW-02). */ +function toItem(row: TourLibraryRow, points: Parameters[1] = []): TourLibraryItem { return { place_id: row.place_id, name: row.name, @@ -36,6 +37,7 @@ function toItem(row: TourLibraryRow): TourLibraryItem { max_hiking_difficulty: row.max_hiking_difficulty, has_waypoints: row.has_waypoints === 1, origin: row.origin as TourOrigin, + ...walkStationFacts(row.tour_type, points), trip: { id: row.trip_id, title: row.trip_title }, }; } @@ -74,8 +76,10 @@ 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); return { - tours: matching.slice(0, TOUR_LIBRARY_LIMIT).map(toItem), + tours: shown.map((row) => toItem(row, walkPoints.get(row.place_id))), trips, truncated: matching.length > TOUR_LIBRARY_LIMIT || rows.length === LIBRARY_READ_LIMIT, }; @@ -85,8 +89,9 @@ 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]); return { - tour: toItem(row), + tour: toItem(row, walkPoints.get(row.place_id)), line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS), waypoint_count: row.waypoint_count, }; diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts index 38f2f1c62..f57326961 100644 --- a/server/src/nest/tours/tours.helpers.ts +++ b/server/src/nest/tours/tours.helpers.ts @@ -3,7 +3,7 @@ * PlacesService prepares and persists imported places; these helpers derive * metrics from the resulting [[lat,lng,ele?]] geometry. */ -import type { GeometryPoint } from '@trek/shared'; +import { walkStationsOf, type GeometryPoint, type TourListItem, type TourWaypoint } from '@trek/shared'; import { createHash } from 'node:crypto'; @@ -30,6 +30,27 @@ export function parseRouteGeometry(routeGeometry: string | null | undefined): Ge } } +/** What a list says of a city walk's stations: how many, their stays as far as entered, and how many have none. */ +export type WalkStationFacts = Pick; + +/** + * CW-02, CW-03: a city walk's stations as a list counts them, from its saved + * points: a shaping point and the way back of a loop are no station, a stay + * nobody entered is counted apart and never as nothing. Any other type has none. + */ +export function walkStationFacts( + tourType: string, + points: readonly Pick[], +): WalkStationFacts { + if (tourType !== 'walk') return { station_count: null, station_stay_minutes: null, station_stays_unknown: null }; + const stations = walkStationsOf(points); + return { + station_count: stations.length, + station_stay_minutes: stations.reduce((sum, station) => sum + (station.stay_minutes ?? 0), 0), + station_stays_unknown: stations.filter((station) => station.stay_minutes == null).length, + }; +} + /** Confidence threshold: below this, the tour imports with a caution flag. */ export const LOW_CONFIDENCE_THRESHOLD = 0.5; diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 997accb7c..70a41fb8d 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -33,6 +33,7 @@ import { localHashOf, parseRouteGeometry, LOW_CONFIDENCE_THRESHOLD, + walkStationFacts, type GeometryPoint, } from './tours.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; @@ -116,7 +117,7 @@ export class ToursService { private readonly paddleChecks: TourPaddleCheckService, ) {} - /** A tour as it is listed; a city walk with the stations its points make (CW-03). */ + /** A tour as it is listed; a city walk with the stations its points make and their stays (CW-02, CW-03). */ private toItem(r: TourListRow, points: readonly TourWaypointRow[] = []): TourListItem { return { place_id: r.place_id, @@ -149,7 +150,7 @@ export class ToursService { has_waypoints: Boolean(r.has_waypoints), revision: r.revision, hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'), - station_count: r.tour_type === 'walk' ? walkStationsOf(points).length : null, + ...walkStationFacts(r.tour_type, points), }; } @@ -225,21 +226,24 @@ export class ToursService { const tid = toRowId(tripId); if (tid === null) return []; const rows = await this.toursRepo.listForTrip(tid); - const walkPoints = await this.walkPointsOf(tid, rows); + const walkPoints = await this.walkPointsByPlace(rows); return await this.paddleChecks.forTrip( tid, rows.map((r) => this.toItem(r, walkPoints.get(r.place_id) ?? [])), ); } - /** CW-03: the points of the trip's city walks by tour, read only where the trip has a walk. */ - private async walkPointsOf( - tripId: number, - rows: readonly TourListRow[], + /** + * 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. + */ + async walkPointsByPlace( + rows: readonly { place_id: number; tour_type: string }[], ): Promise> { const byPlace = new Map(); - if (!rows.some((row) => row.tour_type === 'walk')) return byPlace; - for (const point of await this.waypointsRepo.listForTrip(tripId)) { + const walks = rows.filter((row) => row.tour_type === 'walk').map((row) => row.place_id); + for (const point of await this.waypointsRepo.listForPlaces(walks)) { const points = byPlace.get(point.place_id); if (points) points.push(point); else byPlace.set(point.place_id, [point]); diff --git a/server/tests/e2e/tours-walk.e2e.test.ts b/server/tests/e2e/tours-walk.e2e.test.ts index edefd189f..cdaca631f 100644 --- a/server/tests/e2e/tours-walk.e2e.test.ts +++ b/server/tests/e2e/tours-walk.e2e.test.ts @@ -228,6 +228,9 @@ describe('City walks e2e (real guards + temp SQLite)', () => { place_id: placeId, tour_type: 'walk', station_count: 3, + // CW-02: the cathedral's 30 min and the market passed through; the park's stay is not entered yet. + station_stay_minutes: 30, + station_stays_unknown: 1, max_hiking_difficulty: null, }), ]); diff --git a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts index 4ed43c23e..f2d19206a 100644 --- a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts +++ b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts @@ -1,5 +1,5 @@ /** - * `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3 + * `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW2b (`listForPlaces`), TW3 * (`insertForPlace`) and TW4 (`deleteForPlace`), with a leg that follows a * via ferrata's own line. */ @@ -196,4 +196,31 @@ describe('TourWaypointsRepository', () => { expect(points[1]).not.toHaveProperty('name'); expect(await repo.listForTrip(trip.id)).toEqual(points.map((point) => ({ place_id: place.id, ...point }))); }); + it('TWREPO-010: listForPlaces reads the waypoints of the tours asked for, by place and sequence, and none for none (CW-02)', async () => { + const { trip, place } = tourInTrip(); + const second = createPlace(testDb, trip.id); + createTour(testDb, second.id); + const unasked = createPlace(testDb, trip.id); + createTour(testDb, unasked.id); + await repo.insertForPlace(second.id, [{ lat: 47.2, lng: 11.2, role: 'start', sequence: 0, stay_minutes: 30 }]); + await repo.insertForPlace(place.id, ROUTE); + await repo.insertForPlace(unasked.id, ROUTE); + + const rows = await repo.listForPlaces([second.id, place.id, second.id]); + expect(rows.map((row) => [row.place_id, row.sequence])).toEqual([ + [place.id, 0], + [place.id, 1], + [place.id, 2], + [second.id, 0], + ]); + expect(rows[3]).toEqual({ + place_id: second.id, + lat: 47.2, + lng: 11.2, + role: 'start', + sequence: 0, + stay_minutes: 30, + }); + expect(await repo.listForPlaces([])).toEqual([]); + }); }); diff --git a/server/tests/unit/nest/tours.walk.service.test.ts b/server/tests/unit/nest/tours.walk.service.test.ts index ca15c994e..ff407d106 100644 --- a/server/tests/unit/nest/tours.walk.service.test.ts +++ b/server/tests/unit/nest/tours.walk.service.test.ts @@ -8,6 +8,7 @@ import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity'; import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TourLibraryService } from '../../../src/nest/tours/tour-library.service'; import { TourVisitsService } from '../../../src/nest/tours/tour-visits.service'; import { ToursModule } from '../../../src/nest/tours/tours.module'; import { ToursService } from '../../../src/nest/tours/tours.service'; @@ -95,7 +96,7 @@ async function tripWithDays() { const trip = await makeTrip(orm(), user.id); const day1 = await makeDay(orm(), trip.id, { day_number: 1 }); const day2 = await makeDay(orm(), trip.id, { day_number: 2 }); - return { tripId: String(trip.id), day1, day2 }; + return { tripId: String(trip.id), day1, day2, userId: user.id }; } /** The keys of a tour's points as stored, in route order. */ @@ -254,6 +255,46 @@ describe('ToursService and city walks', () => { ); }); + it('TOURS-WALK-007: a walk lists its stays as far as entered and how many are not, the way back of a loop once (CW-02)', async () => { + const { tripId, userId } = await tripWithDays(); + const library = t.get(TourLibraryService); + // The cathedral's 30 min and the market passed through are entered, the park's stay is not; the shaping point is no stop. + const created = await tours.createTour(tripId, walkBody()); + const entered = { station_count: 3, station_stay_minutes: 30, station_stays_unknown: 1 }; + expect(created.tour).toMatchObject(entered); + const unknown = await tours.createTour( + tripId, + walkBody({ name: 'Unknown', waypoints: inOrder([STATIONS[2], STATIONS[3]].map(plainPoint)) }), + ); + expect(unknown.tour).toMatchObject({ station_count: 2, station_stay_minutes: 0, station_stays_unknown: 2 }); + // A loop saved before the way back stopped copying the start: the start's stay counts once. + const loop = await tours.createTour( + tripId, + walkBody({ name: 'Loop', waypoints: inOrder([...STATIONS, { ...STATIONS[0], key: 'stop-back' }]) }), + ); + expect(loop.tour).toMatchObject(entered); + const hike = await tours.createTour( + tripId, + walkBody({ name: 'Ridge', tour_type: 'hike', waypoints: inOrder([STATIONS[2], STATIONS[3]].map(plainPoint)) }), + ); + const none = { station_count: null, station_stay_minutes: null, station_stays_unknown: null }; + expect(hike.tour).toMatchObject(none); + + const listed = new Map((await tours.listTours(tripId)).map((item) => [item.place_id, item])); + expect(listed.get(created.tour.place_id)).toMatchObject(entered); + expect(listed.get(loop.tour.place_id)).toMatchObject(entered); + expect(listed.get(hike.tour.place_id)).toMatchObject(none); + expect((await tours.getTour(tripId, String(created.tour.place_id))).tour).toMatchObject(entered); + + // Another trip's library lists the walk with its stays too, and the hike with none. + const shelf = new Map((await library.list(userId, {})).tours.map((item) => [item.place_id, item])); + expect(shelf.get(created.tour.place_id)).toMatchObject(entered); + expect(shelf.get(unknown.tour.place_id)).toMatchObject({ station_stay_minutes: 0, station_stays_unknown: 2 }); + expect(shelf.get(hike.tour.place_id)).toMatchObject(none); + expect((await library.detail(userId, loop.tour.place_id)).tour).toMatchObject(entered); + expect((await library.detail(userId, hike.tour.place_id)).tour).toMatchObject(none); + }); + it('TOURS-WALK-005: a GPX file taken in as a city walk has no stations yet', async () => { const { tripId } = await tripWithDays(); const gpx = ` @@ -264,7 +305,14 @@ describe('ToursService and city walks', () => { const imported = await tours.importGpxAsTour(tripId, Buffer.from(gpx), 'harbour.gpx', undefined, { tour_type: 'walk', }); - expect(imported?.tours).toEqual([expect.objectContaining({ tour_type: 'walk', station_count: 0 })]); + expect(imported?.tours).toEqual([ + expect.objectContaining({ + tour_type: 'walk', + station_count: 0, + station_stay_minutes: 0, + station_stays_unknown: 0, + }), + ]); expect((await tours.listTours(tripId))[0]).toMatchObject({ tour_type: 'walk', station_count: 0 }); }); }); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index fcbc8215c..e66f58522 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -294,6 +294,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'وقت المسار المحسوب', 'tours.scope.imported': 'الوقت من الاستيراد', 'tours.scope.planned': 'المخطط مع الاستراحات', + 'tours.scope.plannedWithStays': 'المخطط مع مدد البقاء', 'tours.scope.noMeasures': 'لا مسافة أو ارتفاعات أو أوقات معروفة بعد.', 'tours.library.open': 'نقل جولة من رحلة أخرى', 'tours.library.eyebrow': 'نقل جولة', @@ -501,6 +502,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'وقت المسار سيرًا بين المحطات، دون مدد البقاء.', 'tours.walk.stays': 'مدد البقاء', 'tours.walk.totalAtLeast': '{time} على الأقل', + 'tours.walk.totalComputed': 'محسوب', 'tours.walk.stations': 'محطات الجولة', 'tours.walk.stationNumbered': 'المحطة {number}', 'tours.walk.progress': 'تمت زيارة {visited} من {total}', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index c3d31cc2b..2fcbb8e12 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -283,6 +283,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Marşrut vaxtı', 'tours.scope.imported': 'İdxaldan vaxt', 'tours.scope.planned': 'Fasilələrlə planlaşdırılan', + 'tours.scope.plannedWithStays': 'Qalmalarla planlaşdırılan', 'tours.scope.noMeasures': 'Hələ məsafə, hündürlük və ya vaxt məlum deyil.', 'tours.library.open': 'Başqa səfərdən tur götür', 'tours.library.eyebrow': 'Turu götür', @@ -496,6 +497,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Dayanacaqlar arasında marşrutlaşdırıcının piyada vaxtı, qalma müddətləri olmadan.', 'tours.walk.stays': 'Qalmalar', 'tours.walk.totalAtLeast': 'ən azı {time}', + 'tours.walk.totalComputed': 'Hesablanmış', 'tours.walk.stations': 'Gəzintinin dayanacaqları', 'tours.walk.stationNumbered': 'Dayanacaq {number}', 'tours.walk.progress': '{total} dayanacaqdan {visited} ziyarət edildi', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index b2af87b78..fa595b31d 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -288,6 +288,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Tempo calculado', 'tours.scope.imported': 'Tempo das importações', 'tours.scope.planned': 'Planejado com pausas', + 'tours.scope.plannedWithStays': 'Planejado com permanências', 'tours.scope.noMeasures': 'Ainda sem distância, altitudes ou tempos conhecidos.', 'tours.library.open': 'Trazer um passeio de outra viagem', 'tours.library.eyebrow': 'Trazer passeio', @@ -502,6 +503,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'O tempo a pé do roteador entre as paradas, sem as permanências.', 'tours.walk.stays': 'Permanências', 'tours.walk.totalAtLeast': 'pelo menos {time}', + 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas do passeio', 'tours.walk.stationNumbered': 'Parada {number}', 'tours.walk.progress': '{visited} de {total} visitadas', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 8f8509a5c..d92bfc20a 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -289,6 +289,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Temps calculat', 'tours.scope.imported': 'Temps de les importacions', 'tours.scope.planned': 'Planificat amb pauses', + 'tours.scope.plannedWithStays': 'Planificat amb estades', 'tours.scope.noMeasures': 'Encara no hi ha distància, alçades ni temps coneguts.', 'tours.library.open': "Agafar una ruta d'un altre viatge", 'tours.library.eyebrow': 'Agafar una ruta', @@ -503,6 +504,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'El temps a peu del càlcul de ruta entre parades, sense les estades.', 'tours.walk.stays': 'Estades', 'tours.walk.totalAtLeast': 'com a mínim {time}', + 'tours.walk.totalComputed': 'Calculat', 'tours.walk.stations': 'Parades de la ruta', 'tours.walk.stationNumbered': 'Parada {number}', 'tours.walk.progress': '{visited} de {total} visitades', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index f2f30006a..f0a5a10e4 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -283,6 +283,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Vypočtený čas', 'tours.scope.imported': 'Čas z importů', 'tours.scope.planned': 'Plán s přestávkami', + 'tours.scope.plannedWithStays': 'Plán s pobyty', 'tours.scope.noMeasures': 'Zatím není známa vzdálenost, výšky ani časy.', 'tours.library.open': 'Převzít túru z jiné cesty', 'tours.library.eyebrow': 'Převzít túru', @@ -493,6 +494,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Doba chůze mezi zastávkami podle plánovače trasy, bez pobytů.', 'tours.walk.stays': 'Pobyty', 'tours.walk.totalAtLeast': 'nejméně {time}', + 'tours.walk.totalComputed': 'Vypočteno', 'tours.walk.stations': 'Zastávky procházky', 'tours.walk.stationNumbered': 'Zastávka {number}', 'tours.walk.progress': 'Navštíveno {visited} z {total}', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 9731e1b51..92147dbf3 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -292,6 +292,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Routingzeit', 'tours.scope.imported': 'Zeit laut Import', 'tours.scope.planned': 'Geplant mit Pausen', + 'tours.scope.plannedWithStays': 'Geplant mit Aufenthalten', 'tours.scope.noMeasures': 'Noch keine Distanz, Höhen oder Zeiten bekannt.', 'tours.library.open': 'Tour aus anderer Reise übernehmen', 'tours.library.eyebrow': 'Tour übernehmen', @@ -507,6 +508,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Die Zeit des Routers zu Fuß zwischen den Stationen, ohne die Aufenthalte.', 'tours.walk.stays': 'Aufenthalte', 'tours.walk.totalAtLeast': 'mindestens {time}', + 'tours.walk.totalComputed': 'Berechnet', 'tours.walk.stations': 'Stationen', 'tours.walk.stationNumbered': 'Station {number}', 'tours.walk.progress': '{visited} von {total} besucht', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 43fb6d65e..bc238f2c0 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -282,6 +282,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Routed time', 'tours.scope.imported': 'Time from imports', 'tours.scope.planned': 'Planned with breaks', + 'tours.scope.plannedWithStays': 'Planned with stays', 'tours.scope.noMeasures': 'No distance, heights or times known yet.', 'tours.library.open': 'Take over a tour from another trip', 'tours.library.eyebrow': 'Take over a tour', @@ -494,6 +495,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': "The router's time on foot between the stops, without the stays.", 'tours.walk.stays': 'Stays', 'tours.walk.totalAtLeast': 'at least {time}', + 'tours.walk.totalComputed': 'Computed', 'tours.walk.stations': 'Stops of the walk', 'tours.walk.stationNumbered': 'Stop {number}', 'tours.walk.progress': '{visited} of {total} visited', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 92ae1cf36..ed1552651 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -288,6 +288,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Tiempo calculado', 'tours.scope.imported': 'Tiempo de las importaciones', 'tours.scope.planned': 'Planificado con pausas', + 'tours.scope.plannedWithStays': 'Planificado con estancias', 'tours.scope.noMeasures': 'Aún no se conocen distancia, alturas ni tiempos.', 'tours.library.open': 'Traer una ruta de otro viaje', 'tours.library.eyebrow': 'Traer una ruta', @@ -501,6 +502,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'El tiempo a pie del planificador entre paradas, sin las estancias.', 'tours.walk.stays': 'Estancias', 'tours.walk.totalAtLeast': 'al menos {time}', + 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas del paseo', 'tours.walk.stationNumbered': 'Parada {number}', 'tours.walk.progress': '{visited} de {total} visitadas', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index ab391d9e3..d48e851a7 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -285,6 +285,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Arvutatud aeg', 'tours.scope.imported': 'Aeg importidest', 'tours.scope.planned': 'Planeeritud koos pausidega', + 'tours.scope.plannedWithStays': 'Planeeritud koos peatumistega', 'tours.scope.noMeasures': 'Vahemaa, kõrgused ja ajad pole veel teada.', 'tours.library.open': 'Võta matk üle teiselt reisilt', 'tours.library.eyebrow': 'Matka ülevõtmine', @@ -496,6 +497,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Marsruutija kõndimisaeg peatuste vahel, ilma peatumisteta.', 'tours.walk.stays': 'Peatumised', 'tours.walk.totalAtLeast': 'vähemalt {time}', + 'tours.walk.totalComputed': 'Arvutatud', 'tours.walk.stations': 'Jalutuse peatused', 'tours.walk.stationNumbered': 'Peatus {number}', 'tours.walk.progress': '{visited} / {total} külastatud', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 4d4ed4856..b33350637 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -295,6 +295,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Temps calculé', 'tours.scope.imported': 'Temps des imports', 'tours.scope.planned': 'Prévu avec pauses', + 'tours.scope.plannedWithStays': 'Prévu avec arrêts', 'tours.scope.noMeasures': "Aucune distance, altitude ni durée connue pour l'instant.", 'tours.library.open': "Reprendre un itinéraire d'un autre voyage", 'tours.library.eyebrow': 'Reprendre un itinéraire', @@ -511,6 +512,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': "Le temps à pied du calcul d'itinéraire entre les étapes, sans les arrêts.", 'tours.walk.stays': 'Arrêts', 'tours.walk.totalAtLeast': 'au moins {time}', + 'tours.walk.totalComputed': 'Calculé', 'tours.walk.stations': 'Étapes de la balade', 'tours.walk.stationNumbered': 'Étape {number}', 'tours.walk.progress': '{visited} sur {total} visitées', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 593ebeeb6..58f1087fd 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -295,6 +295,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Υπολογισμένος χρόνος', 'tours.scope.imported': 'Χρόνος από εισαγωγές', 'tours.scope.planned': 'Με διαλείμματα', + 'tours.scope.plannedWithStays': 'Με παραμονές', 'tours.scope.noMeasures': 'Δεν είναι ακόμη γνωστά απόσταση, υψόμετρα ή χρόνοι.', 'tours.library.open': 'Μεταφορά διαδρομής από άλλο ταξίδι', 'tours.library.eyebrow': 'Μεταφορά διαδρομής', @@ -511,6 +512,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Ο χρόνος με τα πόδια του δρομολογητή ανάμεσα στις στάσεις, χωρίς τις παραμονές.', 'tours.walk.stays': 'Παραμονές', 'tours.walk.totalAtLeast': 'τουλάχιστον {time}', + 'tours.walk.totalComputed': 'Υπολογισμένο', 'tours.walk.stations': 'Στάσεις της βόλτας', 'tours.walk.stationNumbered': 'Στάση {number}', 'tours.walk.progress': '{visited} από {total} επισκέψεις', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 2987df6d5..6552cfb7d 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -290,6 +290,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Számított idő', 'tours.scope.imported': 'Idő az importokból', 'tours.scope.planned': 'Tervezett, szünetekkel', + 'tours.scope.plannedWithStays': 'Tervezett, tartózkodásokkal', 'tours.scope.noMeasures': 'Még nem ismert távolság, magasság vagy idő.', 'tours.library.open': 'Túra átvétele másik útból', 'tours.library.eyebrow': 'Túra átvétele', @@ -503,6 +504,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Az útvonaltervező gyalogos ideje a megállók között, a tartózkodások nélkül.', 'tours.walk.stays': 'Tartózkodások', 'tours.walk.totalAtLeast': 'legalább {time}', + 'tours.walk.totalComputed': 'Számított', 'tours.walk.stations': 'A séta megállói', 'tours.walk.stationNumbered': '{number}. megálló', 'tours.walk.progress': '{total} megállóból {visited} meglátogatva', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 34aab4331..8839b2b91 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -282,6 +282,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Waktu perutean', 'tours.scope.imported': 'Waktu dari impor', 'tours.scope.planned': 'Rencana dengan istirahat', + 'tours.scope.plannedWithStays': 'Rencana dengan lama singgah', 'tours.scope.noMeasures': 'Belum ada jarak, ketinggian, atau waktu yang diketahui.', 'tours.library.open': 'Ambil tur dari perjalanan lain', 'tours.library.eyebrow': 'Ambil tur', @@ -495,6 +496,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Waktu jalan kaki dari perute di antara titik singgah, tanpa lama singgah.', 'tours.walk.stays': 'Lama singgah', 'tours.walk.totalAtLeast': 'setidaknya {time}', + 'tours.walk.totalComputed': 'Dihitung', 'tours.walk.stations': 'Titik singgah rute', 'tours.walk.stationNumbered': 'Titik {number}', 'tours.walk.progress': '{visited} dari {total} dikunjungi', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 18c9cacba..d68e3ce6c 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -290,6 +290,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Tempo calcolato', 'tours.scope.imported': 'Tempo dalle importazioni', 'tours.scope.planned': 'Pianificato con pause', + 'tours.scope.plannedWithStays': 'Pianificato con soste', 'tours.scope.noMeasures': 'Nessuna distanza, quota o tempo ancora noto.', 'tours.library.open': 'Riprendi un percorso da un altro viaggio', 'tours.library.eyebrow': 'Riprendi un percorso', @@ -502,6 +503,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Il tempo a piedi del calcolo percorso tra le tappe, senza le soste.', 'tours.walk.stays': 'Soste', 'tours.walk.totalAtLeast': 'almeno {time}', + 'tours.walk.totalComputed': 'Calcolato', 'tours.walk.stations': 'Tappe del percorso', 'tours.walk.stationNumbered': 'Tappa {number}', 'tours.walk.progress': '{visited} su {total} visitate', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 4e3d4b34c..aa5b4dbd1 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -276,6 +276,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'ルート計算の時間', 'tours.scope.imported': 'インポートの時間', 'tours.scope.planned': '休憩込みの計画', + 'tours.scope.plannedWithStays': '滞在込みの計画', 'tours.scope.noMeasures': '距離、高度、時間はまだわかりません。', 'tours.library.open': '別の旅からツアーを取り込む', 'tours.library.eyebrow': 'ツアーを取り込む', @@ -487,6 +488,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'ルート計算による立ち寄り先間の徒歩時間。滞在時間は含みません。', 'tours.walk.stays': '滞在', 'tours.walk.totalAtLeast': '{time} 以上', + 'tours.walk.totalComputed': '計算値', 'tours.walk.stations': '立ち寄り先', 'tours.walk.stationNumbered': '立ち寄り先 {number}', 'tours.walk.progress': '{total} 件中 {visited} 件訪問済み', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 8c838dc45..8e2b4a7c6 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -274,6 +274,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': '경로 계산 시간', 'tours.scope.imported': '가져오기 시간', 'tours.scope.planned': '휴식 포함 계획', + 'tours.scope.plannedWithStays': '머무는 시간 포함 계획', 'tours.scope.noMeasures': '아직 알려진 거리, 고도, 시간이 없습니다.', 'tours.library.open': '다른 여행에서 투어 가져오기', 'tours.library.eyebrow': '투어 가져오기', @@ -483,6 +484,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': '경로 계산기가 낸 정류지 사이 도보 시간이며, 머무는 시간은 빠져 있습니다.', 'tours.walk.stays': '머무는 시간', 'tours.walk.totalAtLeast': '최소 {time}', + 'tours.walk.totalComputed': '계산값', 'tours.walk.stations': '산책 정류지', 'tours.walk.stationNumbered': '정류지 {number}', 'tours.walk.progress': '{total}곳 중 {visited}곳 방문', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index d957c1fdc..68603c4cd 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -291,6 +291,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Berekende tijd', 'tours.scope.imported': 'Tijd uit imports', 'tours.scope.planned': 'Gepland met pauzes', + 'tours.scope.plannedWithStays': 'Gepland met verblijven', 'tours.scope.noMeasures': 'Nog geen afstand, hoogtes of tijden bekend.', 'tours.library.open': 'Tocht overnemen uit een andere reis', 'tours.library.eyebrow': 'Tocht overnemen', @@ -505,6 +506,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'De looptijd van de routeplanner tussen de haltes, zonder het verblijf.', 'tours.walk.stays': 'Verblijven', 'tours.walk.totalAtLeast': 'minstens {time}', + 'tours.walk.totalComputed': 'Berekend', 'tours.walk.stations': 'Haltes van de wandeling', 'tours.walk.stationNumbered': 'Halte {number}', 'tours.walk.progress': '{visited} van {total} bezocht', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index b734cc15a..b8be08c27 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -297,6 +297,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Czas wyliczony', 'tours.scope.imported': 'Czas z importów', 'tours.scope.planned': 'Plan z przerwami', + 'tours.scope.plannedWithStays': 'Plan z postojami', 'tours.scope.noMeasures': 'Brak jeszcze znanego dystansu, wysokości i czasów.', 'tours.library.open': 'Przejmij trasę z innej podróży', 'tours.library.eyebrow': 'Przejmij trasę', @@ -510,6 +511,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Czas marszu między przystankami według planera trasy, bez postojów.', 'tours.walk.stays': 'Postoje', 'tours.walk.totalAtLeast': 'co najmniej {time}', + 'tours.walk.totalComputed': 'Obliczono', 'tours.walk.stations': 'Przystanki trasy', 'tours.walk.stationNumbered': 'Przystanek {number}', 'tours.walk.progress': 'Odwiedzono {visited} z {total}', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index d609da54b..cd45e001d 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -300,6 +300,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Расчётное время', 'tours.scope.imported': 'Время из импорта', 'tours.scope.planned': 'План с перерывами', + 'tours.scope.plannedWithStays': 'План с остановками', 'tours.scope.noMeasures': 'Расстояние, высоты и время пока неизвестны.', 'tours.library.open': 'Взять маршрут из другой поездки', 'tours.library.eyebrow': 'Взять маршрут', @@ -513,6 +514,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Время пешком между остановками по расчёту маршрута, без остановок на месте.', 'tours.walk.stays': 'Остановки', 'tours.walk.totalAtLeast': 'не меньше {time}', + 'tours.walk.totalComputed': 'Расчёт', 'tours.walk.stations': 'Остановки прогулки', 'tours.walk.stationNumbered': 'Остановка {number}', 'tours.walk.progress': 'Посещено {visited} из {total}', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 1479083ed..792c94a16 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -284,6 +284,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Vypočítaný čas', 'tours.scope.imported': 'Čas z importov', 'tours.scope.planned': 'Plán s prestávkami', + 'tours.scope.plannedWithStays': 'Plán s pobytmi', 'tours.scope.noMeasures': 'Zatiaľ nie je známa vzdialenosť, výšky ani časy.', 'tours.library.open': 'Prevziať túru z inej cesty', 'tours.library.eyebrow': 'Prevziať túru', @@ -495,6 +496,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Čas chôdze medzi zastávkami podľa plánovača trasy, bez pobytov.', 'tours.walk.stays': 'Pobyty', 'tours.walk.totalAtLeast': 'najmenej {time}', + 'tours.walk.totalComputed': 'Vypočítané', 'tours.walk.stations': 'Zastávky prechádzky', 'tours.walk.stationNumbered': 'Zastávka {number}', 'tours.walk.progress': 'Navštívené {visited} z {total}', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index c8217fb52..aa977151e 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -281,6 +281,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Beräknad tid', 'tours.scope.imported': 'Tid från importer', 'tours.scope.planned': 'Planerat med pauser', + 'tours.scope.plannedWithStays': 'Planerat med vistelser', 'tours.scope.noMeasures': 'Ingen sträcka, höjd eller tid känd ännu.', 'tours.library.open': 'Hämta en tur från en annan resa', 'tours.library.eyebrow': 'Hämta en tur', @@ -494,6 +495,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Ruttplanerarens gångtid mellan stoppen, utan vistelserna.', 'tours.walk.stays': 'Vistelser', 'tours.walk.totalAtLeast': 'minst {time}', + 'tours.walk.totalComputed': 'Beräknad', 'tours.walk.stations': 'Promenadens stopp', 'tours.walk.stationNumbered': 'Stopp {number}', 'tours.walk.progress': '{visited} av {total} besökta', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 9815c68ef..7c2606812 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -270,6 +270,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'เวลาจากการคำนวณเส้นทาง', 'tours.scope.imported': 'เวลาจากการนำเข้า', 'tours.scope.planned': 'วางแผนรวมพัก', + 'tours.scope.plannedWithStays': 'วางแผนรวมเวลาแวะพัก', 'tours.scope.noMeasures': 'ยังไม่ทราบระยะทาง ความสูง หรือเวลา', 'tours.library.open': 'นำทัวร์จากทริปอื่นมาใช้', 'tours.library.eyebrow': 'นำทัวร์มาใช้', @@ -477,6 +478,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'เวลาเดินระหว่างจุดแวะจากตัวคำนวณเส้นทาง ไม่รวมเวลาแวะพัก', 'tours.walk.stays': 'เวลาแวะพัก', 'tours.walk.totalAtLeast': 'อย่างน้อย {time}', + 'tours.walk.totalComputed': 'ค่าคำนวณ', 'tours.walk.stations': 'จุดแวะของทริป', 'tours.walk.stationNumbered': 'จุดแวะที่ {number}', 'tours.walk.progress': 'แวะแล้ว {visited} จาก {total}', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 2bf9e8d9c..8d25b58a8 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 = 767; +const GENERAL_KEY_COUNT = 769; 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 d3fadb936..a2340a764 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -283,6 +283,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Hesaplanan süre', 'tours.scope.imported': 'İçe aktarmalardan süre', 'tours.scope.planned': 'Molalarla planlanan', + 'tours.scope.plannedWithStays': 'Kalışlarla planlanan', 'tours.scope.noMeasures': 'Henüz bilinen mesafe, yükseklik veya süre yok.', 'tours.library.open': 'Başka bir geziden tur al', 'tours.library.eyebrow': 'Tur al', @@ -497,6 +498,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Rota hesaplayıcının duraklar arasındaki yürüme süresi, kalışlar hariç.', 'tours.walk.stays': 'Kalışlar', 'tours.walk.totalAtLeast': 'en az {time}', + 'tours.walk.totalComputed': 'Hesaplanan', 'tours.walk.stations': 'Yürüyüşün durakları', 'tours.walk.stationNumbered': 'Durak {number}', 'tours.walk.progress': '{total} duraktan {visited} ziyaret edildi', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index b5817a240..0fd49bf23 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -296,6 +296,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Розрахований час', 'tours.scope.imported': 'Час з імпорту', 'tours.scope.planned': 'План з перервами', + 'tours.scope.plannedWithStays': 'План з перебуваннями', 'tours.scope.noMeasures': 'Відстань, висоти й час поки невідомі.', 'tours.library.open': 'Взяти маршрут з іншої подорожі', 'tours.library.eyebrow': 'Взяти маршрут', @@ -509,6 +510,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Час пішки між зупинками за розрахунком маршруту, без перебування.', 'tours.walk.stays': 'Перебування', 'tours.walk.totalAtLeast': 'щонайменше {time}', + 'tours.walk.totalComputed': 'Розрахунок', 'tours.walk.stations': 'Зупинки прогулянки', 'tours.walk.stationNumbered': 'Зупинка {number}', 'tours.walk.progress': 'Відвідано {visited} з {total}', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index d761ef40b..754d6ccde 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -283,6 +283,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': 'Thời gian định tuyến', 'tours.scope.imported': 'Thời gian từ bản nhập', 'tours.scope.planned': 'Kế hoạch gồm nghỉ', + 'tours.scope.plannedWithStays': 'Kế hoạch gồm thời gian dừng', 'tours.scope.noMeasures': 'Chưa biết quãng đường, độ cao hay thời gian.', 'tours.library.open': 'Lấy chuyến từ chuyến đi khác', 'tours.library.eyebrow': 'Lấy chuyến', @@ -494,6 +495,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': 'Thời gian đi bộ giữa các điểm dừng do bộ định tuyến tính, không gồm thời gian dừng.', 'tours.walk.stays': 'Thời gian dừng', 'tours.walk.totalAtLeast': 'ít nhất {time}', + 'tours.walk.totalComputed': 'Đã tính', 'tours.walk.stations': 'Các điểm dừng', 'tours.walk.stationNumbered': 'Điểm dừng {number}', 'tours.walk.progress': 'Đã ghé {visited}/{total}', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 1f96a3ea4..074b05c5e 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -260,6 +260,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': '規劃時間', 'tours.scope.imported': '匯入時間', 'tours.scope.planned': '含休息的計畫', + 'tours.scope.plannedWithStays': '含停留的計畫', 'tours.scope.noMeasures': '尚無已知的距離、高度或時間。', 'tours.library.open': '從其他行程引入路線', 'tours.library.eyebrow': '引入路線', @@ -462,6 +463,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': '路線規劃給出的站點間步行時間,不含停留時間。', 'tours.walk.stays': '停留', 'tours.walk.totalAtLeast': '至少 {time}', + 'tours.walk.totalComputed': '計算值', 'tours.walk.stations': '漫步站點', 'tours.walk.stationNumbered': '第 {number} 站', 'tours.walk.progress': '已到訪 {visited}/{total}', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 4da365749..4ef6e0880 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -261,6 +261,7 @@ const tours: TranslationStrings = { 'tours.scope.routed': '规划时间', 'tours.scope.imported': '导入时间', 'tours.scope.planned': '含休息的计划', + 'tours.scope.plannedWithStays': '含停留的计划', 'tours.scope.noMeasures': '尚无已知的距离、高度或时间。', 'tours.library.open': '从其他行程引入路线', 'tours.library.eyebrow': '引入路线', @@ -463,6 +464,7 @@ const tours: TranslationStrings = { 'tours.walk.timeEstimatedInfo': '路线规划给出的站点间步行时间,不含停留时间。', 'tours.walk.stays': '停留', 'tours.walk.totalAtLeast': '至少 {time}', + 'tours.walk.totalComputed': '计算值', 'tours.walk.stations': '漫步站点', 'tours.walk.stationNumbered': '第 {number} 站', 'tours.walk.progress': '已到访 {visited}/{total}', diff --git a/shared/src/tours/tour-library.schema.ts b/shared/src/tours/tour-library.schema.ts index 2360d7ff9..bf2b223eb 100644 --- a/shared/src/tours/tour-library.schema.ts +++ b/shared/src/tours/tour-library.schema.ts @@ -42,6 +42,9 @@ export const tourLibraryItemSchema = tourListItemSchema max_hiking_difficulty: true, has_waypoints: true, origin: true, + station_count: true, + station_stay_minutes: true, + station_stays_unknown: true, }) .extend({ trip: tourLibraryTripSchema }); export type TourLibraryItem = z.infer; diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index c925ba508..4853ec153 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -237,6 +237,10 @@ export const tourListItemSchema = tourSchema.extend({ revision: tourRevisionSchema.optional(), /** CW-03: a city walk's stations, its points that are no shaping points; null for any other type. */ station_count: z.number().int().nonnegative().nullable().optional(), + /** CW-02: the stays entered at a city walk's stations, summed; null for any other type. */ + station_stay_minutes: z.number().int().nonnegative().nullable().optional(), + /** CW-02: how many of a city walk's stations have no stay entered yet; null for any other type. */ + station_stays_unknown: z.number().int().nonnegative().nullable().optional(), }); export type TourListItem = z.infer;