diff --git a/client/src/components/Tours/TourScopeSummary.tsx b/client/src/components/Tours/TourScopeSummary.tsx index 227e04af2..3911ac05e 100644 --- a/client/src/components/Tours/TourScopeSummary.tsx +++ b/client/src/components/Tours/TourScopeSummary.tsx @@ -3,6 +3,7 @@ import { ChartColumnBig, ChevronDown, Mountain, Route, Waves } from 'lucide-reac import { useMemo, useState, type ComponentType } from 'react'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; import { formatDistance, formatElevation } from '../../utils/units'; import { fs } from '../shared/DialogShell'; import { InfoDot, RailCard, RailFacts, type TourMetricField } from './tourParts'; @@ -129,7 +130,12 @@ function TypeScope({ scope }: { scope: TourTypeScope }) { export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) { const { t } = useTranslation(); const [folded, setFolded] = useState(readFolded); - const scope = useMemo(() => tourScopeSummary(tours), [tours]); + // On a day as the day lists say, not the list's flag, so a tour put on a day counts at once (as its row's badge does). + const assignments = useTripStore((state) => state.assignments); + const scope = useMemo(() => { + const onDays = new Set(Object.values(assignments ?? {}).flatMap((list) => list.map((item) => item.place_id))); + return tourScopeSummary(tours, (tour) => onDays.has(tour.place_id)); + }, [assignments, tours]); if (scope.count === 0) return null; const toggle = () => { setFolded((value) => { diff --git a/client/src/components/Tours/tourScope.test.tsx b/client/src/components/Tours/tourScope.test.tsx index 737129975..c8486e4d8 100644 --- a/client/src/components/Tours/tourScope.test.tsx +++ b/client/src/components/Tours/tourScope.test.tsx @@ -1,6 +1,7 @@ import type { TourListItem } from '@trek/shared'; import { beforeEach, describe, expect, it } from 'vitest'; import { fireEvent, render, screen, within } from '../../../tests/helpers/render'; +import { useTripStore } from '../../store/tripStore'; import { TourScopeSummary } from './TourScopeSummary'; import { tourScopeSummary } from './tourScope'; @@ -60,6 +61,12 @@ const PADDLE = tour({ }); describe('tourScopeSummary (STA-01)', () => { + it('counts a tour on a day by the caller, once however many days it is on', () => { + const scope = tourScopeSummary([HIKE_A, HIKE_B], (tour) => tour.place_id === 2); + expect(scope.onDays).toBe(1); + expect(scope.types[0].onDays).toBe(1); + }); + it('counts each tour once, by type, the most tours first, with how many are on a day', () => { const scope = tourScopeSummary([HIKE_A, PADDLE, HIKE_B, GPX_HIKE, HIKE_A]); expect(scope.count).toBe(4); @@ -95,7 +102,13 @@ describe('tourScopeSummary (STA-01)', () => { }); describe('TourScopeSummary', () => { - beforeEach(() => localStorage.clear()); + beforeEach(() => { + localStorage.clear(); + // The day lists say which tours are on a day: HIKE_B on two days, PADDLE on one; the flags say otherwise. + useTripStore.setState({ + assignments: { 1: [{ place_id: 2 }], 2: [{ place_id: 2 }, { place_id: 4 }] }, + } as unknown as Parameters[0]); + }); it('shows the counts and each type with its sums, an incomplete one marked as at least', () => { render(); diff --git a/client/src/components/Tours/tourScope.ts b/client/src/components/Tours/tourScope.ts index 5756ba372..143beea15 100644 --- a/client/src/components/Tours/tourScope.ts +++ b/client/src/components/Tours/tourScope.ts @@ -48,7 +48,7 @@ function sumOf(values: Array): ScopeSum { }; } -function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[]): TourTypeScope { +function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[], onDay: OnDay): TourTypeScope { const planned = tours.filter((tour) => tourSource(tour) === 'trek'); const imported = tours.filter((tour) => tourSource(tour) !== 'trek'); // A paddle tour drawn in TREK has no true heights (they would be the banks'), so it does not count as having them. @@ -58,7 +58,7 @@ function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[]): TourTyp return { type, count: tours.length, - onDays: tours.filter((tour) => tour.planned).length, + onDays: tours.filter(onDay).length, distanceKm: sumOf(tours.map((tour) => tour.distance)), ascentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_gain)) : NO_SUM, descentM: heights ? sumOf(withHeights.map((tour) => tour?.elevation_loss)) : NO_SUM, @@ -68,18 +68,22 @@ function scopeOfType(type: TourTypeKey, tours: readonly TourListItem[]): TourTyp }; } +/** Whether a tour is on a day of the trip. */ +type OnDay = (tour: TourListItem) => boolean; + /** * STA-01: what the tours of a trip add up to, by type. Each tour counts once, * however many days it is on; the list is the trip's tours, one row each, so - * a tour on three days is still one tour and one distance. + * a tour on three days is still one tour and one distance. `onDay` reads the + * day lists where the caller has them; the list's own flag is the fallback. */ -export function tourScopeSummary(tours: readonly TourListItem[]): TourScope { +export function tourScopeSummary(tours: readonly TourListItem[], onDay: OnDay = (tour) => tour.planned): TourScope { const byType = new Map(); for (const tour of new Map(tours.map((tour) => [tour.place_id, tour])).values()) { byType.set(tour.tour_type, [...(byType.get(tour.tour_type) ?? []), tour]); } const types = [...byType.entries()] - .map(([type, ofType]) => scopeOfType(type, ofType)) + .map(([type, ofType]) => scopeOfType(type, ofType, onDay)) .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)); return { count: types.reduce((total, scope) => total + scope.count, 0),