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, + }; +}