+
@@ -321,7 +323,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
)}
{field.value}
diff --git a/client/src/components/Tours/walk/walkTimeFields.test.ts b/client/src/components/Tours/walk/walkTimeFields.test.ts
index 8b9d05b67..1f739f030 100644
--- a/client/src/components/Tours/walk/walkTimeFields.test.ts
+++ b/client/src/components/Tours/walk/walkTimeFields.test.ts
@@ -75,6 +75,15 @@ describe('walkTimeFields', () => {
'Computed',
'Not calculated',
]);
+ // A column too narrow for the full label names the manual total by what sets it apart.
+ const manual = walkTimeFields({
+ walking,
+ walkingMinutes: 45,
+ stays: { stayMinutes: 0, unknownStays: 0, stations: 2 },
+ manualMinutes: 240,
+ t,
+ })[2];
+ expect(manual).toMatchObject({ label: 'Planned (Manual override)', shortLabel: 'Manual override' });
});
it('keeps the walking label and its explanation as the planner hands them in', () => {
diff --git a/client/src/components/Tours/walk/walkTimeFields.ts b/client/src/components/Tours/walk/walkTimeFields.ts
index 014ed3630..3919b859e 100644
--- a/client/src/components/Tours/walk/walkTimeFields.ts
+++ b/client/src/components/Tours/walk/walkTimeFields.ts
@@ -57,6 +57,8 @@ export function walkTimeFields({
...parts,
{
label: `${planned} (${t('tours.planner.plannedTotalManual')})`,
+ // A narrow column names it by what sets it apart from the computed total beside it.
+ shortLabel: t('tours.planner.plannedTotalManual'),
value: formatPlannedTourDuration(manualMinutes),
},
{ label: t('tours.walk.totalComputed'), value: computed },
diff --git a/client/src/repo/tourRepo.test.ts b/client/src/repo/tourRepo.test.ts
index 611cbb783..a5aa35cc4 100644
--- a/client/src/repo/tourRepo.test.ts
+++ b/client/src/repo/tourRepo.test.ts
@@ -9,6 +9,7 @@ import { server } from '../../tests/helpers/msw/server'
import { tourRepo } from './tourRepo'
import { offlineDb, clearAll, replaceTripTours } from '../db/offlineDb'
import { buildPlace } from '../../tests/helpers/factories'
+import { plannedTotalText, tourPlannedTimes } from '../components/Tours/tourPresentation'
function setOnline(v: boolean): void {
Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true })
@@ -225,6 +226,51 @@ describe('tourRepo.update', () => {
})
})
+describe('tourRepo offline copies of a city walk (CW-02)', () => {
+ // 45 minutes of walking, a museum stayed at for 90 minutes, a shaping point and a cafe nobody entered a stay for yet.
+ const walkBody: TourCreateRequest = {
+ name: 'Old town',
+ tour_type: 'walk',
+ route_geometry: [[48.1, 11.5, 520], [48.11, 11.51, 521], [48.12, 11.52, 522]],
+ waypoints: [
+ { lat: 48.1, lng: 11.5, role: 'start', sequence: 0, name: 'Museum', stay_minutes: 90 },
+ { lat: 48.11, lng: 11.51, role: 'via', sequence: 1, shaping: true },
+ { lat: 48.12, lng: 11.52, role: 'end', sequence: 2, name: 'Cafe', stay_minutes: null },
+ ],
+ max_hiking_difficulty: null,
+ duration_seconds: 2700,
+ }
+ const noTranslation = (key: string) => key
+
+ it('FE-REPO-TOUR-015: offline, a new walk carries its stops and stays, so the day row reads at least walking plus the stays entered', async () => {
+ setOnline(false)
+
+ const { tour: walk } = await tourRepo.create(3, walkBody)
+ const { tour: hike } = await tourRepo.create(3, body)
+
+ expect(walk).toMatchObject({ duration: 45, station_count: 2, station_stay_minutes: 90, station_stays_unknown: 1 })
+ expect(await offlineDb.tours.get(walk.place_id)).toMatchObject({ station_stay_minutes: 90, station_stays_unknown: 1 })
+ expect(plannedTotalText(tourPlannedTimes(walk), noTranslation, true)).toBe('≥ 2 h 15 min')
+ expect(hike).toMatchObject({ station_count: null, station_stay_minutes: null, station_stays_unknown: null })
+ })
+
+ it('FE-REPO-TOUR-016: offline, a stay changed while the write waits shows at once, not the stays of the last answer', async () => {
+ setOnline(false)
+ await offlineDb.tours.put({
+ ...tour({ tour_type: 'walk', duration: 45, station_count: 2, station_stay_minutes: 120, station_stays_unknown: 0 }),
+ trip_id: 3,
+ })
+ const [museum, steer, cafe] = walkBody.waypoints
+ const edited = { ...walkBody, waypoints: [{ ...museum, stay_minutes: 30 }, steer, cafe] }
+
+ const { tour: walk } = await tourRepo.update(3, 40, edited)
+
+ expect(walk).toMatchObject({ station_count: 2, station_stay_minutes: 30, station_stays_unknown: 1 })
+ expect(await offlineDb.tours.get(40)).toMatchObject({ station_stay_minutes: 30, station_stays_unknown: 1 })
+ expect(plannedTotalText(tourPlannedTimes(walk), noTranslation, true)).toBe('≥ 1 h 15 min')
+ })
+})
+
describe('tourRepo writes and the revision (P2C-03)', () => {
it('FE-REPO-TOUR-030: online, the revision the draft was based on goes along; queued, it stays behind', async () => {
let sentBody: Record | null = null
diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts
index 2e418c9c0..27875d344 100644
--- a/client/src/repo/tourRepo.ts
+++ b/client/src/repo/tourRepo.ts
@@ -1,4 +1,4 @@
-import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared'
+import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
import { toursApi } from '../api/client'
import { tourGpxApi } from '../api/tourGpx'
import { recordedTrackApi } from '../api/recordedTrack'
@@ -61,7 +61,8 @@ function endpointsOf(routeGeometry: unknown): TourWaypoint[] {
/**
* What the server will derive from a create or edit, worked out here for the
* copy shown until the queued write replays: the same metrics, the duration
- * in minutes, full confidence because the route editor drew it.
+ * in minutes, full confidence because the route editor drew it, and a city
+ * walk's stations with the stays as now entered (CW-02).
*/
function routeFields(body: TourCreateRequest) {
const metrics = computeTourMetrics(body.route_geometry as GeometryPoint[])
@@ -82,6 +83,7 @@ function routeFields(body: TourCreateRequest) {
caution: false,
max_hiking_difficulty: body.max_hiking_difficulty,
has_waypoints: true,
+ ...walkStationFacts(body.tour_type, body.waypoints),
...(body.planned_duration_minutes !== undefined ? { planned_duration_minutes: body.planned_duration_minutes } : {}),
...(body.break_additional_minutes !== undefined ? { break_additional_minutes: body.break_additional_minutes } : {}),
}
diff --git a/server/src/nest/tours/tour-library.service.ts b/server/src/nest/tours/tour-library.service.ts
index dd9caa998..37c8d5c52 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, walkStationFacts } from './tours.helpers';
+import { parseRouteGeometry, thinnedLine } from './tours.helpers';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
@@ -16,6 +16,7 @@ import {
type TourOrigin,
type TourTakeOverRequest,
type TourTypeKey,
+ walkStationFacts,
} from '@trek/shared';
/** The most rows one list reads before the name is matched; past it the answer says it was cut. */
diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts
index f57326961..38f2f1c62 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 { walkStationsOf, type GeometryPoint, type TourListItem, type TourWaypoint } from '@trek/shared';
+import type { GeometryPoint } from '@trek/shared';
import { createHash } from 'node:crypto';
@@ -30,27 +30,6 @@ 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 70a41fb8d..926aa0f5d 100644
--- a/server/src/nest/tours/tours.service.ts
+++ b/server/src/nest/tours/tours.service.ts
@@ -33,7 +33,6 @@ import {
localHashOf,
parseRouteGeometry,
LOW_CONFIDENCE_THRESHOLD,
- walkStationFacts,
type GeometryPoint,
} from './tours.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -58,6 +57,7 @@ import {
type TourProvenanceSource,
type TourWaypoint,
type WandererDifficulty,
+ walkStationFacts,
walkStationsOf,
wandererDifficultyToSac,
} from '@trek/shared';
diff --git a/shared/src/tours/tour-walk.spec.ts b/shared/src/tours/tour-walk.spec.ts
index b20ead7d3..d5efd3fb8 100644
--- a/shared/src/tours/tour-walk.spec.ts
+++ b/shared/src/tours/tour-walk.spec.ts
@@ -1,4 +1,4 @@
-import { isClosedLoop, walkStationsOf } from './tour-walk';
+import { isClosedLoop, walkStationFacts, walkStationsOf } from './tour-walk';
import { describe, expect, it } from 'vitest';
@@ -33,3 +33,38 @@ describe('walkStationsOf (CW-02, CW-03)', () => {
expect(walkStationsOf([])).toEqual([]);
});
});
+
+describe('walkStationFacts (CW-02, CW-03)', () => {
+ it('counts the stops of a walk, sums the stays entered and counts the ones nobody entered apart', () => {
+ const museum = { ...market, stay_minutes: 90 };
+ const cafe = { ...park, stay_minutes: 30 };
+ expect(walkStationFacts('walk', [cathedral, steer, museum, cafe])).toEqual({
+ station_count: 3,
+ station_stay_minutes: 120,
+ station_stays_unknown: 1,
+ });
+ });
+
+ it('counts the start of a loop once, with its stay, and a stay of 0 as entered', () => {
+ const start = { ...cathedral, stay_minutes: 20 };
+ const passed = { ...market, stay_minutes: 0 };
+ expect(walkStationFacts('walk', [start, passed, { ...start }])).toEqual({
+ station_count: 2,
+ station_stay_minutes: 20,
+ station_stays_unknown: 0,
+ });
+ expect(walkStationFacts('walk', [])).toEqual({
+ station_count: 0,
+ station_stay_minutes: 0,
+ station_stays_unknown: 0,
+ });
+ });
+
+ it('says nothing of the stations of any other type', () => {
+ expect(walkStationFacts('hike', [cathedral, { ...market, stay_minutes: 30 }])).toEqual({
+ station_count: null,
+ station_stay_minutes: null,
+ station_stays_unknown: null,
+ });
+ });
+});
diff --git a/shared/src/tours/tour-walk.ts b/shared/src/tours/tour-walk.ts
index 1f370e2d4..1be888ae1 100644
--- a/shared/src/tours/tour-walk.ts
+++ b/shared/src/tours/tour-walk.ts
@@ -31,3 +31,30 @@ export function walkStationsOf(po
const route = isClosedLoop(points) ? points.slice(0, -1) : points;
return route.filter((point) => !point.shaping);
}
+
+/** What a list says of a city walk's stations: how many, their stays as far as entered, and how many have none. */
+interface WalkStationFacts {
+ station_count: number | null;
+ station_stay_minutes: number | null;
+ station_stays_unknown: number | null;
+}
+
+/**
+ * CW-02, CW-03: a city walk's stations as a list counts them, from its 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. The
+ * server answers a list with it, and the client works out the same for a walk
+ * saved offline until the write replays.
+ */
+export function walkStationFacts(
+ tourType: string,
+ points: readonly (Spot & { shaping?: boolean | null; stay_minutes?: number | null })[],
+): 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,
+ };
+}