mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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:
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user