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 });
}
/**