mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
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.
This commit is contained in:
@@ -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(<TourDetailDialog {...defaultProps} tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />)
|
||||
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(<TourDetailDialog {...defaultProps}
|
||||
tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1, planned_duration_minutes: 240 }} />)
|
||||
|
||||
@@ -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<WalkTimes, 'stayMinutes' | 'unknownStays' | 'stations'> | 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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
<div key={field.label} className="min-w-0">
|
||||
<div key={field.label} className="flex min-w-0 flex-col">
|
||||
<dt className="mb-[3px] flex min-w-0 items-center gap-0.5">
|
||||
<Eyebrow className="relative min-w-0 truncate">
|
||||
<FitLabel full={field.label} short={field.shortLabel} />
|
||||
@@ -321,7 +323,7 @@ export function TourFactFields({ fields, columns }: { fields: readonly TourMetri
|
||||
)}
|
||||
</dt>
|
||||
<dd
|
||||
className={`${BOX} m-0 truncate px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
|
||||
className={`${BOX} m-0 flex flex-1 flex-col justify-center break-words px-[10px] py-[7px] text-center font-semibold tabular-nums text-content`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{field.value}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string, unknown> | null = null
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<TourListItem, 'station_count' | 'station_stay_minutes' | 'station_stays_unknown'>;
|
||||
|
||||
/**
|
||||
* 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<TourWaypoint, 'lat' | 'lng' | 'shaping' | 'stay_minutes'>[],
|
||||
): 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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,3 +31,30 @@ export function walkStationsOf<T extends Spot & { shaping?: boolean | null }>(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user