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.
This commit is contained in:
Maurice
2026-10-20 17:00:27 +02:00
parent e40e576e39
commit b284095ee0
6 changed files with 158 additions and 4 deletions
@@ -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<TourActivities> {
'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<number>().as('n'))
@@ -28,6 +28,12 @@ interface ActivityStatsInput {
tracks: readonly TourStatsTrackRow[];
/** The tours the user left out of their own sums. */
excluded: ReadonlySet<number>;
/**
* 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<number, readonly string[]>;
}
/**
@@ -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);
@@ -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<Map<number, string[]>> {
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<number, typeof points>();
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<TourActivityStatsResponse> {
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 });
}
/**
@@ -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();
});
});
@@ -20,6 +20,7 @@ function row(overrides: Partial<TourStatsExecutionRow> = {}): 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' }]);
});
});
});
@@ -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 }]);
});
});