From 6bd19e22947c5bb07b99639acb5a0bc3b2ae6a7e Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 01:53:24 +0000 Subject: [PATCH] fix(tours): a city walk saved offline shows its stays and total at once, and the planner plans the total its overview shows (CW-02) A city walk created or changed while offline now carries its stops, the minutes its stays are entered with and how many have none yet, worked out from its points the way the server will answer them. The day row, the tour detail and the summary show the walking time with the stays as soon as the walk is saved, with "at least" where a stay is missing, and a stay changed while the write waits shows its new total rather than the one of the last answer. The server and the client count the stops in one shared place. The planner plans a walk whose walking time is not known yet with at least the stays entered, as its overview says, so the planned field and the check against the longest plan read the same total. In the tour detail a longer time such as an "at least" total wraps on a phone instead of being cut, and the boxes of a row keep one height. A total entered by hand falls back to its short name where its column is too narrow. --- .../Tours/TourDetailDialog.test.tsx | 12 ++++- .../Tours/planner/tourPlannerFacts.ts | 26 +++++++++-- .../Tours/planner/tourPlannerParts.test.ts | 30 ++++++++++-- .../Tours/planner/useTourPlanner.ts | 9 ++-- client/src/components/Tours/tourParts.tsx | 8 ++-- .../Tours/walk/walkTimeFields.test.ts | 9 ++++ .../components/Tours/walk/walkTimeFields.ts | 2 + client/src/repo/tourRepo.test.ts | 46 +++++++++++++++++++ client/src/repo/tourRepo.ts | 6 ++- server/src/nest/tours/tour-library.service.ts | 3 +- server/src/nest/tours/tours.helpers.ts | 23 +--------- server/src/nest/tours/tours.service.ts | 2 +- shared/src/tours/tour-walk.spec.ts | 37 ++++++++++++++- shared/src/tours/tour-walk.ts | 27 +++++++++++ 14 files changed, 196 insertions(+), 44 deletions(-) diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index dd52a137e..7c063ca99 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TourListItem } from '@trek/shared' import { buildPlace } from '../../../tests/helpers/factories' import { paddleBench } from '../../../tests/helpers/paddleCheck' @@ -61,6 +61,11 @@ beforeEach(() => { seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) }) +// A repository answer one test stubs never reaches the tests after it. +afterEach(() => { + vi.restoreAllMocks() +}) + describe('TourDetailDialog', () => { it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => { const opener = document.createElement('button') @@ -159,6 +164,11 @@ describe('TourDetailDialog', () => { view.rerender() expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min')) .toHaveTextContent('≥ 2 h 15 min') + // On a phone's three narrow columns the longer values wrap rather than being cut. + const stays = screen.getByText('Stays').closest('dt')?.nextElementSibling + expect(stays).toHaveTextContent('at least 1 h 30 min') + expect(stays).toHaveClass('break-words') + expect(stays).not.toHaveClass('truncate') view.rerender() diff --git a/client/src/components/Tours/planner/tourPlannerFacts.ts b/client/src/components/Tours/planner/tourPlannerFacts.ts index 560cb6790..692d6a782 100644 --- a/client/src/components/Tours/planner/tourPlannerFacts.ts +++ b/client/src/components/Tours/planner/tourPlannerFacts.ts @@ -13,6 +13,7 @@ import { } from '@trek/shared'; import type { RouteGeometryAnalysis } from '../../../utils/routeGeometry'; import { hikingTimeMinutes } from '../hikingTime'; +import { walkTimesOf, type WalkTimes } from './stationModel'; /** The editable fields of a saved tour as the planner opens them, each with its empty value. */ export function openedTourFields(tour: TourListItem) { @@ -52,8 +53,11 @@ export interface TourPlannerFactsInput { /** A paddle tour's estimate on the water; null for a hike. */ paddleEstimate: { waterMinutes: number | null; estimateMinutes: number | null } | null; routeAnalysis: RouteGeometryAnalysis | null; - /** CW-02: a city walk's stays, summed as far as they are known; null for any other type, which takes its breaks. */ - stationStayMinutes?: number | null; + /** + * CW-02: a city walk's stays as far as known, and of how many stations; null + * for any other type, which takes its breaks. + */ + stays?: Pick | null; /** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */ durationSeconds: number | null; } @@ -78,6 +82,16 @@ function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | nul : routerMinutes; } +/** + * CW-02: a city walk's times, the total its overview shows. Its stops are its + * stays and it has no breaks on top, so nothing counts twice; the stays entered + * count even while the walking time is not known. Null for any other type. + */ +function walkTimesFor(input: TourPlannerFactsInput, walkingMinutes: number | null): WalkTimes | null { + if (input.tourType !== 'walk') return null; + return walkTimesOf({ stayMinutes: 0, unknownStays: 0, stations: 0, ...input.stays, walkingMinutes }); +} + /** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */ function typeFieldsOf(input: TourPlannerFactsInput) { const isBike = input.tourType === 'bike'; @@ -118,9 +132,9 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input; const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60); const walkingDurationMinutes = movingMinutes(input, routerMinutes); - // A city walk's stops are its stays; it has no breaks on top, so nothing counts twice. - const onTop = input.tourType === 'walk' ? (input.stationStayMinutes ?? 0) : (breakAdditionalMinutes ?? 0); - const automaticPlannedTotalMinutes = walkingDurationMinutes === null ? null : walkingDurationMinutes + onTop; + const walk = walkTimesFor(input, walkingDurationMinutes); + const withBreaks = walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0); + const automaticPlannedTotalMinutes = walk ? walk.totalMinutes : withBreaks; const { boatType, waterContext } = input.paddle; return { isPaddle, @@ -130,6 +144,8 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) { breakAdditionalInvalid: input.tourType !== 'walk' && invalidMinutes(breakAdditionalMinutes), paddleIncomplete: isPaddle && (boatType === null || waterContext === null), walkingDurationMinutes, + /** CW-02: a city walk's walking time, stays and total; null for any other type. */ + stationStays: walk, automaticPlannedTotalMinutes, plannedTotalMinutes: plannedDurationMinutes ?? automaticPlannedTotalMinutes, plannedTotalInvalid: diff --git a/client/src/components/Tours/planner/tourPlannerParts.test.ts b/client/src/components/Tours/planner/tourPlannerParts.test.ts index 2eb8c2c0d..1f4eabcc7 100644 --- a/client/src/components/Tours/planner/tourPlannerParts.test.ts +++ b/client/src/components/Tours/planner/tourPlannerParts.test.ts @@ -96,7 +96,7 @@ describe('tourPlannerFacts', () => { routeAnalysis: analysis, durationSeconds: 3_000, breakAdditionalMinutes: 40, - stationStayMinutes: 75, + stays: { stayMinutes: 75, unknownStays: 0, stations: 2 }, hikingPreferences: { avoid_steps: true }, }); const facts = tourPlannerFacts(walk); @@ -111,10 +111,34 @@ describe('tourPlannerFacts', () => { bike_profile: null, boat_type: null, }); + expect(facts.stationStays).toMatchObject({ + walkingMinutes: 50, + stayMinutes: 75, + totalMinutes: 125, + partial: false, + }); // Stays nobody entered count for nothing; a total typed by hand still wins. - expect(tourPlannerFacts({ ...walk, stationStayMinutes: null }).automaticPlannedTotalMinutes).toBe(50); + expect(tourPlannerFacts({ ...walk, stays: null }).automaticPlannedTotalMinutes).toBe(50); expect(tourPlannerFacts({ ...walk, plannedDurationMinutes: 180 }).plannedTotalMinutes).toBe(180); - expect(tourPlannerFacts({ ...walk, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull(); + expect(tourPlannerFacts({ ...walk, stays: null, durationSeconds: null }).automaticPlannedTotalMinutes).toBeNull(); + }); + + it('CW-02: a walk whose walking time is not known plans at least the stays entered, as its overview says', () => { + const walk = hike({ + tourType: 'walk', + durationSeconds: null, + stays: { stayMinutes: 120, unknownStays: 1, stations: 3 }, + }); + const facts = tourPlannerFacts(walk); + expect(facts.walkingDurationMinutes).toBeNull(); + expect(facts.plannedTotalMinutes).toBe(120); + expect(facts.stationStays).toMatchObject({ walkingMinutes: null, totalMinutes: 120, partial: true }); + expect(facts.plannedTotalInvalid).toBe(false); + // Stays past the longest plan a tour may have hold the save up as the walking time would. + expect( + tourPlannerFacts({ ...walk, stays: { stayMinutes: 1_500, unknownStays: 0, stations: 2 } }).plannedTotalInvalid + ).toBe(true); + expect(tourPlannerFacts(hike({ durationSeconds: 600 })).stationStays).toBeNull(); }); }); diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 06e351d59..2c2ac027d 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -5,7 +5,7 @@ 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, walkTimesOf } from './stationModel' +import { walkTimes } from './stationModel' import { hasCompleteElevation, useTourRoute } from './useTourRoute' import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, routeInputsKey, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, withSavedKeys, type TourPlannerWaypoint } from './tourPlannerWaypoints' import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts' @@ -499,13 +499,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const facts = useMemo(() => tourPlannerFacts({ tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes, paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle }, - paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stationStayMinutes: stays?.stayMinutes ?? null, + paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, }), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid, plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stays, tourType, waterContext, website]) + // CW-02: a city walk's times as the overview says them, from the same derivation as its planned total. const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes, - 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]) + plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, savedBreakAdditionalMinutes, typeFields, stationStays } = facts const returnToNeutral = useCallback(() => { if (saveInFlightRef.current) return diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index b248f8ae9..77e20693c 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -301,7 +301,9 @@ type FieldColumns = keyof typeof FIELD_COLUMNS /** * Labelled values in the booking-card look: a fixed number of columns where the - * caller wants one row per group, otherwise as many as fit. + * caller wants one row per group, otherwise as many as fit. A value too long for + * its column, such as a city walk's "at least" total on a phone, wraps rather + * than being cut, and the boxes of a row keep one height. */ export function TourFactFields({ fields, columns }: { fields: readonly TourMetricField[]; columns?: FieldColumns }) { return ( @@ -309,7 +311,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri className={`m-0 grid gap-2 ${columns ? FIELD_COLUMNS[columns] : 'grid-cols-[repeat(auto-fit,minmax(104px,1fr))]'}`} > {fields.map((field) => ( -
+
@@ -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, + }; +}