fix(tours): count the trip totals' tours on a day from the day lists (STA-01)

The totals counted a tour as on a day by the list's planned flag, which
lags behind a day assignment made a moment ago. They read the day lists
now, as the row's day badge does, so the count follows at once.
This commit is contained in:
Maurice
2026-10-19 13:56:05 +02:00
parent a87ce65899
commit e4b49e1148
3 changed files with 30 additions and 7 deletions
@@ -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) => {
+14 -1
View File
@@ -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<typeof useTripStore.setState>[0]);
});
it('shows the counts and each type with its sums, an incomplete one marked as at least', () => {
render(<TourScopeSummary tours={[HIKE_A, HIKE_B, PADDLE]} />);
+9 -5
View File
@@ -48,7 +48,7 @@ function sumOf(values: Array<number | null | undefined>): 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<TourTypeKey, TourListItem[]>();
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),