From b284095ee05efb4abf247ceed74bd8d48c79d9b9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 17:00:27 +0200 Subject: [PATCH] fix(tours): count a multi-day tour in the activity sums only once all its stages are on days The activity sums never read which stage a day holds, so a multi-day tour counted its whole route on the last dated stage day while other stages were on no day yet, and a recording on one stage took the place of the whole plan beside it. With stages on days of their own, the route now counts once every stage is on a dated day of the user's, on the last of them, and is uncounted until then; a recording counts as what was done and the plan is not added beside it. --- .../repositories/TourActivities.repository.ts | 3 ++ .../nest/tours/tour-activity-stats.helpers.ts | 47 ++++++++++++++++ .../nest/tours/tour-activity-stats.service.ts | 22 ++++++-- .../TourActivities.stats.repository.test.ts | 9 ++++ .../nest/tour-activity-stats.helpers.test.ts | 54 +++++++++++++++++++ .../nest/tour-activity-stats.service.test.ts | 27 +++++++++- 6 files changed, 158 insertions(+), 4 deletions(-) diff --git a/server/src/db/repositories/TourActivities.repository.ts b/server/src/db/repositories/TourActivities.repository.ts index 843bf38ea..23a67a7a7 100644 --- a/server/src/db/repositories/TourActivities.repository.ts +++ b/server/src/db/repositories/TourActivities.repository.ts @@ -52,6 +52,8 @@ export interface TourStatsExecutionRow { trip_title: string; assignment_id: number | null; date: string | null; + /** MDT-02: the stage of a multi-day tour the day holds, by the key of its first stop; null for the whole tour. */ + tour_stage_key: string | null; participant_count: number; joined: number; } @@ -192,6 +194,7 @@ export class TourActivitiesRepository extends TrekRepository { 'tr.title as trip_title', 'da.id as assignment_id', 'd.date as date', + 'da.tour_stage_key as tour_stage_key', eb .selectFrom('assignment_participants as ap') .select(eb.fn.countAll().as('n')) diff --git a/server/src/nest/tours/tour-activity-stats.helpers.ts b/server/src/nest/tours/tour-activity-stats.helpers.ts index 9e9ee154f..6f30415f0 100644 --- a/server/src/nest/tours/tour-activity-stats.helpers.ts +++ b/server/src/nest/tours/tour-activity-stats.helpers.ts @@ -28,6 +28,12 @@ interface ActivityStatsInput { tracks: readonly TourStatsTrackRow[]; /** The tours the user left out of their own sums. */ excluded: ReadonlySet; + /** + * MDT-02: the stages of each multi-day tour whose stages are put on days, + * by the key of the stop each starts at (`tourStageKeysOf`). A tour not in + * it is counted whole, as a day tour is. + */ + stageKeys?: ReadonlyMap; } /** @@ -157,7 +163,48 @@ function entriesOfTour( /** What became of one tour: its entries, or why a tour of the user's cannot count. */ type TourOutcome = TourActivityStatsEntry[] | 'unassigned' | 'undated' | null; +/** The dates on which the user does one stage: its own days, and the days that hold the whole tour. */ +function stageDates(tour: TourOfStats, key: string): { any: boolean; dates: string[] } { + const own = tour.executions.filter( + (row) => isOwnExecution(row) && (row.tour_stage_key === null || row.tour_stage_key === key), + ); + const dates = own.flatMap((row) => { + const date = calendarDay(row.date); + return date === null ? [] : [date]; + }); + return { any: own.length > 0, dates }; +} + +/** + * MDT-02: a multi-day tour whose stages are on days of their own. The route + * is measured whole, so it counts once only when every stage is on a dated + * day of the user's, on the last of those days; whether that day has passed + * is the summary's to say. Until then it is uncounted: on no day while a + * stage is on none of the user's days, undated while one is only on days + * without a date. A recording counts as what was done, in place of the plan: + * the plan of the stages it does not cover is not measured apart, so it is + * left out rather than counted with the whole route a second time. + */ +function stagedOutcome(tour: TourOfStats, keys: readonly string[], input: ActivityStatsInput): TourOutcome { + const { own, dated } = ownExecutions(tour); + if (own === 0) return null; + const last = dated.at(-1); + if (last) { + const tracks = input.tracks.filter((track) => track.place_id === tour.row.place_id); + const recorded = entriesOfTour(tour, dated, last, tracks, input.excluded); + if (tracks.length > 0 && recorded.every((entry) => entry.basis === 'track')) return recorded; + } + const stages = keys.map((key) => stageDates(tour, key)); + if (stages.some((stage) => !stage.any)) return 'unassigned'; + if (stages.some((stage) => stage.dates.length === 0) || !last) return 'undated'; + const lastDate = stages.flatMap((stage) => stage.dates).reduce((a, b) => (b > a ? b : a)); + return [planEntry(tour, { assignmentId: last.assignmentId, date: lastDate }, dated.length, input.excluded)]; +} + function outcomeOf(tour: TourOfStats, input: ActivityStatsInput): TourOutcome { + const keys = input.stageKeys?.get(tour.row.place_id) ?? []; + const stagesOnDays = keys.length > 1 && tour.executions.some((row) => row.tour_stage_key !== null); + if (stagesOnDays) return stagedOutcome(tour, keys, input); const { own, dated } = ownExecutions(tour); const last = dated.at(-1); if (last) return entriesOfTour(tour, dated, last, input.tracks, input.excluded); diff --git a/server/src/nest/tours/tour-activity-stats.service.ts b/server/src/nest/tours/tour-activity-stats.service.ts index 5ced2dbe1..daa3ca95c 100644 --- a/server/src/nest/tours/tour-activity-stats.service.ts +++ b/server/src/nest/tours/tour-activity-stats.service.ts @@ -1,14 +1,16 @@ import { TourActivities } from '../../db/entities/TourActivities.entity'; import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity'; +import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import { Tours } from '../../db/entities/Tours.entity'; -import type { TourActivitiesRepository } from '../../db/repositories/TourActivities.repository'; +import type { TourActivitiesRepository, TourStatsExecutionRow } from '../../db/repositories/TourActivities.repository'; import type { TourActivityExclusionsRepository } from '../../db/repositories/TourActivityExclusions.repository'; +import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository'; import type { ToursRepository } from '../../db/repositories/Tours.repository'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; import { buildActivityStats } from './tour-activity-stats.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; -import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared'; +import { tourStageKeysOf, type TourActivityExclusionRequest, type TourActivityStatsResponse } from '@trek/shared'; /** * STA-02: the user's own tour activities for the Atlas, across every trip the @@ -25,8 +27,21 @@ export class TourActivityStatsService { @InjectRepository(Tours) private readonly toursRepo: ToursRepository, @InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository, @InjectRepository(TourActivityExclusions) private readonly exclusionsRepo: TourActivityExclusionsRepository, + @InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository, ) {} + /** + * MDT-02: the stages of each tour that has a stage on a day of its own, by + * the key of the stop each starts at, read from the tour's saved points. + */ + private async stageKeysOf(executions: readonly TourStatsExecutionRow[]): Promise> { + const placeIds = [...new Set(executions.filter((row) => row.tour_stage_key !== null).map((row) => row.place_id))]; + const points = await this.waypointsRepo.listForPlaces(placeIds); + const byTour = new Map(); + for (const point of points) byTour.set(point.place_id, [...(byTour.get(point.place_id) ?? []), point]); + return new Map([...byTour].map(([placeId, tourPoints]) => [placeId, tourStageKeysOf(tourPoints)])); + } + async stats(userId: number): Promise { const tripIds = await this.membership.listAccessibleTripIds(userId); const [executions, tracks, excluded] = await Promise.all([ @@ -34,7 +49,8 @@ export class TourActivityStatsService { this.activitiesRepo.listTracksForStats(tripIds), this.exclusionsRepo.listPlaceIds(userId), ]); - return buildActivityStats({ executions, tracks, excluded: new Set(excluded) }); + const stageKeys = await this.stageKeysOf(executions); + return buildActivityStats({ executions, tracks, excluded: new Set(excluded), stageKeys }); } /** diff --git a/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts b/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts index 8e9a1af17..b2e63ff4b 100644 --- a/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts +++ b/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts @@ -84,6 +84,7 @@ describe('TourActivitiesRepository stats reads (STA-02)', () => { derived_from_place_id: null, trip_id: trip.id, trip_title: 'Alps', + tour_stage_key: null, participant_count: 1, joined: 0, }); @@ -126,4 +127,12 @@ describe('TourActivitiesRepository stats reads (STA-02)', () => { expect(await repo.listTracksForStats([theirs.id])).toEqual([]); expect(await repo.listTracksForStats([])).toEqual([]); }); + + it('STA2-REPO-014: names the stage a day holds of a multi-day tour, and none for a day that holds the whole tour', async () => { + const { user, trip, day, hike, onDay } = await fixture(); + const onStage = await makeDayAssignment(t, day.id, hike.id, { tour_stage_key: 'hut-1' }); + const rows = await repo.listExecutionsForStats([trip.id], user.id); + expect(rows.find((row) => row.assignment_id === onStage.id)?.tour_stage_key).toBe('hut-1'); + expect(rows.find((row) => row.assignment_id === onDay.id)?.tour_stage_key).toBeNull(); + }); }); diff --git a/server/tests/unit/nest/tour-activity-stats.helpers.test.ts b/server/tests/unit/nest/tour-activity-stats.helpers.test.ts index fdcdb4daa..11795c7b7 100644 --- a/server/tests/unit/nest/tour-activity-stats.helpers.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.helpers.test.ts @@ -20,6 +20,7 @@ function row(overrides: Partial = {}): TourStatsExecution trip_title: 'Alps', assignment_id: 100, date: '2026-07-02', + tour_stage_key: null, participant_count: 0, joined: 0, ...overrides, @@ -225,4 +226,57 @@ describe('buildActivityStats (STA-02)', () => { ['Zugspitze', '2026-07-02'], ]); }); + + describe('a multi-day tour with its stages on days of their own (MDT-02)', () => { + const keys = new Map([[10, ['start', 'hut-1', 'hut-2']]]); + const staged = (executions: TourStatsExecutionRow[], tracks: TourStatsTrackRow[] = []) => + buildActivityStats({ executions, tracks, excluded: new Set(), stageKeys: keys }); + const stage = (assignment_id: number, tour_stage_key: string, date: string | null) => + row({ assignment_id, tour_stage_key, date }); + + it('STA2-HELP-017: counts the whole route once, on the last stage day, only once every stage is on a dated day', () => { + const all = staged([ + stage(100, 'start', '2026-07-02'), + stage(101, 'hut-1', '2026-07-03'), + stage(102, 'hut-2', '2026-07-04'), + ]); + expect(all.entries).toHaveLength(1); + expect(all.entries[0]).toMatchObject({ basis: 'plan', date: '2026-07-04', distance_km: 12.5, day_count: 3 }); + + // One stage on no day: the route is not counted on the days of the other two. + const partly = staged([stage(100, 'start', '2026-07-02'), stage(101, 'hut-1', '2026-07-03')]); + expect(partly.entries).toEqual([]); + expect(partly.uncounted).toEqual({ unassigned: 1, undated: 0 }); + + const undated = staged([ + stage(100, 'start', '2026-07-02'), + stage(101, 'hut-1', '2026-07-03'), + stage(102, 'hut-2', null), + ]); + expect(undated.entries).toEqual([]); + expect(undated.uncounted).toEqual({ unassigned: 0, undated: 1 }); + + // A day that holds the whole tour covers every stage. + const whole = staged([stage(100, 'start', '2026-07-02'), row({ assignment_id: 103, date: '2026-07-05' })]); + expect(whole.entries).toMatchObject([{ basis: 'plan', date: '2026-07-05' }]); + }); + + it('STA2-HELP-018: a recording on one stage counts as what was done, never beside the whole route', () => { + const result = staged( + [stage(100, 'start', '2026-07-02'), stage(101, 'hut-1', '2026-07-03'), stage(102, 'hut-2', '2026-07-04')], + [track({ assignment_id: 101 })], + ); + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ basis: 'track', date: '2026-07-03', day_count: 1 }); + expect(result.entries.some((entry) => entry.basis === 'plan')).toBe(false); + }); + + it('STA2-HELP-019: a tour without stage keys, or with none of its days on a stage, is counted whole as before', () => { + const plain = buildActivityStats({ executions: [row()], tracks: [], excluded: new Set(), stageKeys: keys }); + expect(plain.entries).toMatchObject([{ basis: 'plan', date: '2026-07-02' }]); + const executions = [stage(100, 'start', '2026-07-02')]; + const noKeys = buildActivityStats({ executions, tracks: [], excluded: new Set() }); + expect(noKeys.entries).toMatchObject([{ basis: 'plan', date: '2026-07-02' }]); + }); + }); }); diff --git a/server/tests/unit/nest/tour-activity-stats.service.test.ts b/server/tests/unit/nest/tour-activity-stats.service.test.ts index 8715d388c..4da3f94fc 100644 --- a/server/tests/unit/nest/tour-activity-stats.service.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.service.test.ts @@ -13,7 +13,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { makeDayAssignment } from '../../helpers/factories/itinerary'; import { makePlace } from '../../helpers/factories/places'; import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; -import { makeTour, makeTourActivity } from '../../helpers/factories/tours'; +import { addTourWaypoints, makeTour, makeTourActivity } from '../../helpers/factories/tours'; import { addTripMember, makeDay, makeTrip } from '../../helpers/factories/trips'; import { makeUser } from '../../helpers/factories/users'; import { resetTestDb } from '../../helpers/test-db'; @@ -155,4 +155,29 @@ describe('TourActivityStatsService', () => { const { user } = await makeUser(orm()); expect(await service.stats(user.id)).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 } }); }); + + it('STA2-SVC-007: a multi-day tour with a stage on no day is not counted on the days of its other stages (MDT-02)', async () => { + const { user } = await makeUser(orm()); + const trip = await makeTrip(orm(), user.id, { title: 'Hut to hut' }); + const first = await makeDay(orm(), trip.id, { date: '2026-07-02' }); + const second = await makeDay(orm(), trip.id, { date: '2026-07-03' }); + const route = await makePlace(orm(), trip.id, { name: 'Three huts' }); + await makeTour(orm(), route.id, { distance: 40 }); + await addTourWaypoints(orm(), route.id, [ + { lat: 47, lng: 11, station_key: 'start' }, + { lat: 47.1, lng: 11, station_key: 'hut-1', stage_end: 1 }, + { lat: 47.2, lng: 11, station_key: 'hut-2', stage_end: 1 }, + { lat: 47.3, lng: 11, station_key: 'end' }, + ]); + await makeDayAssignment(orm(), first.id, route.id, { tour_stage_key: 'start' }); + await makeDayAssignment(orm(), second.id, route.id, { tour_stage_key: 'hut-1' }); + const partly = await service.stats(user.id); + expect(partly.entries).toEqual([]); + expect(partly.uncounted).toEqual({ unassigned: 1, undated: 0 }); + + const third = await makeDay(orm(), trip.id, { date: '2026-07-04' }); + await makeDayAssignment(orm(), third.id, route.id, { tour_stage_key: 'hut-2' }); + const whole = await service.stats(user.id); + expect(whole.entries).toMatchObject([{ place_id: route.id, basis: 'plan', date: '2026-07-04', distance_km: 40 }]); + }); });