- {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')}
- />
-
- void saveName()}
- disabled={savingName}
- aria-label={t('common.save')}
- className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
- >
-
-
-
-
- ) : (
-
-
- {place.name}
-
- {canEdit && onUpdatePlace && (
-
- {
- setNameDraft(place.name);
- setEditingName(true);
- }}
- aria-label={t('common.edit')}
- className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
- >
-
-
-
- )}
-
- )}
+
[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 (
-
-
-
-
-
- {t('tours.planner.freeLegBadge')}
-
-
- {distance}
-
-
-
-
- );
-}
-
/** 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 (
+
+
+
+
+
+ {t('tours.planner.freeLegBadge')}
+
+
+ {distance}
+
+
+
+
+ );
+}
+
+/**
+ * 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;