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;