feat(atlas): sum up the tours you have done, by period and type (STA-02)

With the Tours addon on, the Atlas panel gets a third tab, Tours, beside
Stats and Bucket List: what you have done on your tours across all your
trips. A period (all time, this year, the last 12 months or an earlier year)
and a tour type narrow it; the tab shows the distance large with the number
of tours at its corner, the ascent and descent, and the types with their
distance. "Show tours" opens every tour of the period in a dialog with the
same filters, the sums, the split by type and one row per tour.

What counts is said, never guessed. A tour counts once the day it is on is
over, with the values of the planning, badged "Planned" and explained as
not measured. A linked actual track (ACT-01) replaces those values for its
execution, badged "Recorded" and named, and is never added on top nor
counted as a tour of its own. A tour on several days counts once, a whole
route does not count beside the stages planned from it, a paddle tour sums
no heights, and a value nobody knows stays out of the sum, which then reads
"at least". Only the user's own part counts: a day names them among its
participants, or names nobody on a trip of their own; being able to open
somebody else's trip makes none of its tours theirs. Tours still ahead, on
no day or only on undated days are named as not counting. A tour that fell
through can be left out of the sums ("Leave out"): it stays in the plan
and in the list, dimmed, and "Count again" brings it back. Only the user's
own numbers change.

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
This commit is contained in:
Maurice
2026-10-20 13:43:12 +02:00
parent ae79ab8dad
commit bde2ad2ca8
76 changed files with 4717 additions and 16 deletions
+1
View File
@@ -109,6 +109,7 @@
"TodoCategoryAssignees": "todo",
"TodoItems": "todo",
"TourActivities": "tours",
"TourActivityExclusions": "tours",
"TourChangeSteps": "tours",
"TourObstacleReviews": "tours",
"TourStationVisits": "tours",
@@ -0,0 +1,29 @@
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourActivityExclusionsRepository } from '../repositories/TourActivityExclusions.repository';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
export class TourActivityExclusions {
[EntityRepositoryType]?: TourActivityExclusionsRepository;
id!: number & Opt;
user!: Ref<Users>;
user_id!: number;
place!: Ref<Tours>;
place_id!: number;
excluded_at!: string & Opt;
}
export const TourActivityExclusionsSchema = defineEntity({
class: TourActivityExclusions,
repository: () => TourActivityExclusionsRepository,
uniques: [{ properties: ['user', 'place'] }],
properties: {
id: p.integer().primary(),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(),
user_id: p.integer().persist(false),
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden().index('idx_tour_activity_exclusions_place'),
place_id: p.integer().persist(false).index('idx_tour_activity_exclusions_place'),
excluded_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
},
});
+3
View File
@@ -3,6 +3,7 @@ import { ToursRepository } from '../repositories/Tours.repository';
import { DbTimestampType } from '../types';
import { Places } from './Places.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourChangeSteps } from './TourChangeSteps.entity';
import { TourObstacleReviews } from './TourObstacleReviews.entity';
import { TourSources } from './TourSources.entity';
@@ -52,6 +53,7 @@ export class Tours {
tourTypeRef!: Ref<TourTypes>;
tour_activities_collection = new Collection<TourActivities>(this);
tour_activities_collection1 = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_change_steps_collection = new Collection<TourChangeSteps>(this);
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -187,6 +189,7 @@ export const ToursSchema = defineEntity({
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('place').hidden(),
tour_activities_collection1: () => p.oneToMany(TourActivities).mappedBy('trackPlace').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('place').hidden(),
tour_change_steps_collection: () => p.oneToMany(TourChangeSteps).mappedBy('place').hidden(),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+3
View File
@@ -52,6 +52,7 @@ import { Tags } from './Tags.entity';
import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity';
import { TodoItems } from './TodoItems.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
@@ -180,6 +181,7 @@ export class Users {
todo_category_assignees_collection = new Collection<TodoCategoryAssignees>(this);
todo_items_collection = new Collection<TodoItems>(this);
tour_activities_collection = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
@@ -326,6 +328,7 @@ export const UsersSchema = defineEntity({
todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(),
todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
+2
View File
@@ -108,6 +108,7 @@ import { TagsSchema } from './Tags.entity';
import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
import { TodoItemsSchema } from './TodoItems.entity';
import { TourActivitiesSchema } from './TourActivities.entity';
import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity';
import { TourChangeStepsSchema } from './TourChangeSteps.entity';
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
import { TourSourcesSchema } from './TourSources.entity';
@@ -255,6 +256,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TodoCategoryAssigneesSchema,
TodoItemsSchema,
TourActivitiesSchema,
TourActivityExclusionsSchema,
TourChangeStepsSchema,
TourObstacleReviewsSchema,
TourSourcesSchema,
@@ -0,0 +1,10 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TourActivityExclusionsTable {
id: InsertOptional<number>;
user_id: number;
place_id: number;
excluded_at: InsertOptional<string>;
}
+2
View File
@@ -114,6 +114,7 @@ import type { TagsTable } from './Tags.table';
import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table';
import type { TodoItemsTable } from './TodoItems.table';
import type { TourActivitiesTable } from './TourActivities.table';
import type { TourActivityExclusionsTable } from './TourActivityExclusions.table';
import type { TourChangeStepsTable } from './TourChangeSteps.table';
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
import type { TourSourcesTable } from './TourSources.table';
@@ -267,6 +268,7 @@ export interface DB {
todo_category_assignees: TodoCategoryAssigneesTable;
todo_items: TodoItemsTable;
tour_activities: TourActivitiesTable;
tour_activity_exclusions: TourActivityExclusionsTable;
tour_change_steps: TourChangeStepsTable;
tour_obstacle_reviews: TourObstacleReviewsTable;
tour_sources: TourSourcesTable;
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* STA-02: a user can leave a tour out of their own activity sums in the Atlas,
* a tour that fell through, without touching the tour: its line, its days and
* its measures stay as they are, and nobody else's sums change. One row per
* user and tour (`UNIQUE(user_id, place_id)`); deleting either takes the row
* with it.
*/
export class Migration20200101045910_a_tour_can_be_left_out_of_ones_activities extends Migration {
override name = 'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_activity_exclusions (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
excluded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, place_id)
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_activity_exclusions_place ON tour_activity_exclusions(place_id)');
}
}
@@ -2,7 +2,10 @@ import type { TourActivities } from '../entities/TourActivities.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type ActivitiesDB = Pick<DB, 'tour_activities' | 'tours' | 'places' | 'day_assignments'>;
type ActivitiesDB = Pick<
DB,
'tour_activities' | 'tours' | 'places' | 'day_assignments' | 'days' | 'trips' | 'assignment_participants'
>;
/** One assignment of a recording to a tour, as the trip's tour list names it. */
export interface TourActivityRefRow {
@@ -30,6 +33,40 @@ export interface TourActivityRow extends TourActivityRefRow {
track_ended_at: string | null;
}
/**
* STA-02: one tour of a trip the user can open, once per day assignment it has
* (once with `assignment_id` NULL when it is on no day), with what the activity
* sums need: its plan's measures, the day's date, the trip's owner, and who the
* execution names as taking part (`participant_count`, and `joined` 1 when the
* user is one of them).
*/
export interface TourStatsExecutionRow {
place_id: number;
name: string;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
derived_from_place_id: number | null;
trip_id: number;
trip_title: string;
trip_owner_id: number;
assignment_id: number | null;
date: string | null;
participant_count: number;
joined: number;
}
/** STA-02: a recording assigned to a tour, with the line its values are read from. */
export interface TourStatsTrackRow {
place_id: number;
track_place_id: number;
assignment_id: number | null;
track_name: string;
track_route_geometry: string | null;
track_recorded_route_geometry: string | null;
}
/** What an assignment writes: the tour, its recording, the execution and who confirmed it. */
interface TourActivityInsert {
place_id: number;
@@ -130,6 +167,81 @@ export class TourActivitiesRepository extends TrekRepository<TourActivities> {
.execute();
}
/**
* STA-02: every tour of the given trips with each of its day assignments, for
* the user's activity sums. The day is read only when it is a day of the
* tour's own trip. Never a trip outside `trip_ids`, which the caller resolved
* from the user's own access.
*/
async listExecutionsForStats(trip_ids: readonly number[], user_id: number): Promise<TourStatsExecutionRow[]> {
if (trip_ids.length === 0) return [];
const rows = await this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.innerJoin('trips as tr', 'tr.id', 'p.trip_id')
.leftJoin('day_assignments as da', 'da.place_id', 'p.id')
.leftJoin('days as d', (join) => join.onRef('d.id', '=', 'da.day_id').onRef('d.trip_id', '=', 'p.trip_id'))
.select((eb) => [
'p.id as place_id',
'p.name as name',
't.tour_type as tour_type',
't.distance as distance',
't.elevation_gain as elevation_gain',
't.elevation_loss as elevation_loss',
't.derived_from_place_id as derived_from_place_id',
'tr.id as trip_id',
'tr.title as trip_title',
'tr.user_id as trip_owner_id',
'da.id as assignment_id',
'd.date as date',
eb
.selectFrom('assignment_participants as ap')
.select(eb.fn.countAll<number>().as('n'))
.whereRef('ap.assignment_id', '=', 'da.id')
.as('participant_count'),
eb
.exists(
eb
.selectFrom('assignment_participants as me')
.select('me.id')
.whereRef('me.assignment_id', '=', 'da.id')
.where('me.user_id', '=', user_id),
)
.as('joined'),
])
.where('p.trip_id', 'in', [...trip_ids])
.orderBy('p.id', 'asc')
.orderBy('da.id', 'asc')
.execute();
return rows.map((row) => ({
...row,
participant_count: Number(row.participant_count ?? 0),
joined: Number(row.joined),
}));
}
/** STA-02: the recordings assigned to tours of the given trips, each with its line, both tours in the same trip. */
async listTracksForStats(trip_ids: readonly number[]): Promise<TourStatsTrackRow[]> {
if (trip_ids.length === 0) return [];
return await this.db()
.selectFrom('tour_activities as a')
.innerJoin('places as p', 'p.id', 'a.place_id')
.innerJoin('places as tp', 'tp.id', 'a.track_place_id')
.innerJoin('tours as tt', 'tt.place_id', 'a.track_place_id')
.select([
'a.place_id as place_id',
'a.track_place_id as track_place_id',
'a.assignment_id as assignment_id',
'tp.name as track_name',
'tp.route_geometry as track_route_geometry',
'tt.recorded_route_geometry as track_recorded_route_geometry',
])
.where('p.trip_id', 'in', [...trip_ids])
.whereRef('tp.trip_id', '=', 'p.trip_id')
.orderBy('a.id', 'asc')
.execute();
}
/** Writes one assignment and answers its id. */
async insertActivity(input: TourActivityInsert): Promise<number> {
const row = await this.db().insertInto('tour_activities').values(input).returning('id').executeTakeFirstOrThrow();
@@ -0,0 +1,46 @@
import type { TourActivityExclusions } from '../entities/TourActivityExclusions.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type ExclusionsDB = Pick<DB, 'tour_activity_exclusions'>;
/**
* `tour_activity_exclusions` (STA-02): the tours a user left out of their own
* activity sums. Personal: every statement names the user, so one person's
* choice never reaches anyone else's sums. The caller checks that the user
* can open the tour's trip before writing.
*/
export class TourActivityExclusionsRepository extends TrekRepository<TourActivityExclusions> {
private db() {
return this.kysely<ExclusionsDB>();
}
/** The tours the user left out, by place id. */
async listPlaceIds(user_id: number): Promise<number[]> {
const rows = await this.db()
.selectFrom('tour_activity_exclusions')
.select('place_id')
.where('user_id', '=', user_id)
.orderBy('place_id', 'asc')
.execute();
return rows.map((row) => row.place_id);
}
/** Leaves the tour out of the user's sums; a second call changes nothing. */
async exclude(user_id: number, place_id: number): Promise<void> {
await this.db()
.insertInto('tour_activity_exclusions')
.values({ user_id, place_id })
.onConflict((oc) => oc.columns(['user_id', 'place_id']).doNothing())
.execute();
}
/** Counts the tour again; nothing to do when it was not left out. */
async include(user_id: number, place_id: number): Promise<void> {
await this.db()
.deleteFrom('tour_activity_exclusions')
.where('user_id', '=', user_id)
.where('place_id', '=', place_id)
.execute();
}
}
@@ -0,0 +1,5 @@
import { tourActivityExclusionRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourActivityExclusionDto extends createZodDto(tourActivityExclusionRequestSchema) {}
@@ -0,0 +1,37 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TourActivityExclusionDto } from './dto/tour-activity-stats.dto';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { tourActivityStatsResponseSchema } from '@trek/shared';
/**
* STA-02 /api/tours/activity-stats: the user's own tour activities across
* their trips, for the Atlas, and leaving one out of them. Not trip-scoped,
* so there is no TripAccessGuard; the service bounds every answer by the
* user's own trips. Leaving a tour out changes only the user's own sums, so it
* asks for no trip permission beyond being able to open the trip.
*/
@Controller('api/tours/activity-stats')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourActivityStatsController {
constructor(private readonly stats: TourActivityStatsService) {}
@Get()
@ResponseContract(tourActivityStatsResponseSchema)
async list(@CurrentUser() user: User) {
return await this.stats.stats(user.id);
}
@Put('exclusions')
@ResponseContract(tourActivityStatsResponseSchema)
async setExcluded(@CurrentUser() user: User, @Body() body: TourActivityExclusionDto) {
return await this.stats.setExcluded(user.id, body);
}
}
@@ -0,0 +1,184 @@
/**
* STA-02: the pure half of the user's activity sums. The rows are every tour
* of the trips the user can open, once per day it is on, and the recordings
* assigned to them; what comes out is the list of the user's done (or still
* upcoming) tours the shared summary adds up, see `tour-activity-stats.schema`
* in @trek/shared for the rules this follows.
*/
import type { TourStatsExecutionRow, TourStatsTrackRow } from '../../db/repositories/TourActivities.repository';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import type { TourActivityStatsEntry, TourActivityStatsResponse, TourTypeKey } from '@trek/shared';
/** One tour with its executions, as the rows give it. */
interface TourOfStats {
row: TourStatsExecutionRow;
executions: TourStatsExecutionRow[];
}
/** One execution of a tour that is the user's and on a day with a date. */
interface DatedExecution {
assignmentId: number;
date: string;
}
interface ActivityStatsInput {
userId: number;
executions: readonly TourStatsExecutionRow[];
tracks: readonly TourStatsTrackRow[];
/** The tours the user left out of their own sums. */
excluded: ReadonlySet<number>;
}
/**
* Whether an execution is the user's own: it names the user among those taking
* part, or names nobody on a trip that is the user's. Being able to open
* someone else's trip alone makes none of its tours the user's.
*/
export function isOwnExecution(row: TourStatsExecutionRow, userId: number): boolean {
if (row.joined === 1) return true;
return row.participant_count === 0 && row.trip_owner_id === userId;
}
/** A trip day's date as a calendar day, null when the day has none. */
function calendarDay(date: string | null): string | null {
const day = date?.slice(0, 10) ?? null;
return day && /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
}
function groupByTour(rows: readonly TourStatsExecutionRow[]): TourOfStats[] {
const tours = new Map<number, TourOfStats>();
for (const row of rows) {
const tour = tours.get(row.place_id) ?? { row, executions: [] };
if (row.assignment_id !== null) tour.executions.push(row);
tours.set(row.place_id, tour);
}
return [...tours.values()];
}
/** The user's executions of a tour, and of those the ones on a dated day, the earliest first. */
function ownExecutions(tour: TourOfStats, userId: number): { own: number; dated: DatedExecution[] } {
const own = tour.executions.filter((row) => isOwnExecution(row, userId));
const dated: DatedExecution[] = [];
for (const row of own) {
const date = calendarDay(row.date);
if (date !== null && row.assignment_id !== null) dated.push({ assignmentId: row.assignment_id, date });
}
dated.sort((a, b) => a.date.localeCompare(b.date) || a.assignmentId - b.assignmentId);
return { own: own.length, dated };
}
/** A measure as the contract carries it: unknown stays null, never a negative. */
function measure(value: number | null): number | null {
return value !== null && Number.isFinite(value) && value >= 0 ? value : null;
}
/** On the water a climb says nothing, so a paddle tour carries no heights, planned or recorded. */
function heights(type: string, gain: number | null, loss: number | null) {
return type === 'kayak' ? { ascent_m: null, descent_m: null } : { ascent_m: measure(gain), descent_m: measure(loss) };
}
function baseEntry(tour: TourOfStats, excluded: ReadonlySet<number>) {
const { row } = tour;
return {
place_id: row.place_id,
name: row.name,
tour_type: row.tour_type as TourTypeKey,
trip: { id: row.trip_id, title: row.trip_title },
excluded: excluded.has(row.place_id),
};
}
/** The plan standing in for the tour: once, on the first of its days, however many there are. */
function planEntry(
tour: TourOfStats,
first: DatedExecution,
dayCount: number,
excluded: ReadonlySet<number>,
): TourActivityStatsEntry {
const { row } = tour;
return {
...baseEntry(tour, excluded),
date: first.date,
basis: 'plan',
track: null,
day_count: dayCount,
distance_km: measure(row.distance),
...heights(row.tour_type, row.elevation_gain, row.elevation_loss),
};
}
/** A recording in the plan's place, measured on its own line: the line it kept if it was planned over since. */
function trackEntry(
tour: TourOfStats,
track: TourStatsTrackRow,
date: string,
excluded: ReadonlySet<number>,
): TourActivityStatsEntry {
const line = parseRouteGeometry(track.track_recorded_route_geometry ?? track.track_route_geometry);
const metrics = computeTourMetrics(line);
return {
...baseEntry(tour, excluded),
date,
basis: 'track',
track: { place_id: track.track_place_id, name: track.track_name },
day_count: 1,
distance_km: line.length >= 2 ? metrics.distanceKm : null,
...heights(tour.row.tour_type, metrics.elevationGainM, metrics.elevationLossM),
};
}
/**
* A tour's entries: one per recording of one of the user's executions (a
* recording on no day counts with the first of them), or else the plan once.
* A recording of someone else's execution is theirs, not the user's.
*/
function entriesOfTour(
tour: TourOfStats,
[first, ...rest]: [DatedExecution, ...DatedExecution[]],
tracks: readonly TourStatsTrackRow[],
excluded: ReadonlySet<number>,
): TourActivityStatsEntry[] {
const dateOf = new Map([first, ...rest].map((execution) => [execution.assignmentId, execution.date]));
const recorded = tracks
.filter((track) => track.place_id === tour.row.place_id)
.map((track) => ({ track, date: track.assignment_id === null ? first.date : dateOf.get(track.assignment_id) }))
.filter((item): item is { track: TourStatsTrackRow; date: string } => item.date !== undefined);
if (recorded.length === 0) return [planEntry(tour, first, rest.length + 1, excluded)];
return recorded.map(({ track, date }) => trackEntry(tour, track, date, excluded));
}
/** What became of one tour: its entries, or why a tour of the user's own trip cannot count. */
type TourOutcome = TourActivityStatsEntry[] | 'unassigned' | 'undated' | null;
function outcomeOf(tour: TourOfStats, input: ActivityStatsInput): TourOutcome {
const { own, dated } = ownExecutions(tour, input.userId);
const [first, ...rest] = dated;
if (first) return entriesOfTour(tour, [first, ...rest], input.tracks, input.excluded);
if (tour.row.trip_owner_id !== input.userId) return null;
if (tour.executions.length === 0) return 'unassigned';
return own > 0 ? 'undated' : null;
}
/**
* The user's activity entries and what of their own trips cannot count. A
* recording is never a tour of its own here, and an import other tours were
* planned from (a whole route and its stages) is left out while one of those
* tours counts, so nothing is added up twice.
*/
export function buildActivityStats(input: ActivityStatsInput): TourActivityStatsResponse {
const recordings = new Set(input.tracks.map((track) => track.track_place_id));
const tours = groupByTour(input.executions).filter((tour) => !recordings.has(tour.row.place_id));
const outcomes = tours.map((tour) => ({ tour, outcome: outcomeOf(tour, input) }));
const stagesCounted = new Set(
outcomes.filter(({ outcome }) => Array.isArray(outcome)).map(({ tour }) => tour.row.derived_from_place_id),
);
const uncounted = { unassigned: 0, undated: 0 };
const entries: TourActivityStatsEntry[] = [];
for (const { tour, outcome } of outcomes) {
if (outcome === null || stagesCounted.has(tour.row.place_id)) continue;
if (Array.isArray(outcome)) entries.push(...outcome);
else uncounted[outcome] += 1;
}
entries.sort((a, b) => b.date.localeCompare(a.date) || a.name.localeCompare(b.name) || a.place_id - b.place_id);
return { entries, uncounted };
}
@@ -0,0 +1,89 @@
import { ADDON_IDS } from '../../addons';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
errorResult,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { NotFoundException } from '@nestjs/common';
import {
summarizeTourActivities,
tourActivityExclusionRequestSchema,
tourActivityStatsQuerySchema,
type TourActivityExclusionRequest,
type TourActivityStatsQuery,
type TourActivityStatsResponse,
} from '@trek/shared';
const query = tourActivityStatsQuerySchema.shape;
const exclusion = tourActivityExclusionRequestSchema.shape;
/** Today as a calendar day in UTC: the assistant has no time zone of the user's to go by. */
function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
/** The sums of the period and type, the tours they are made of, and what of the user's trips cannot count. */
function answer(stats: TourActivityStatsResponse, filter: TourActivityStatsQuery = {}) {
const today = todayUtc();
const summary = summarizeTourActivities(stats.entries, { today, ...filter });
return {
today,
totals: summary.totals,
by_type: summary.types,
tours: summary.done,
upcoming: summary.upcoming,
uncounted: stats.uncounted,
};
}
/**
* STA-02: the user's own tour activities and leaving one out of them, over
* the same service as the Atlas: every trip the user can open, only the
* user's own part of it, and the user's own exclusions.
*/
@McpController()
export class TourActivityStatsMcp {
constructor(
private readonly stats: TourActivityStatsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
@Tool({
name: 'get_tour_activity_stats',
description:
'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the day it is on is over (today is UTC here), and only where the user took part: a day of the tour names the user among its participants, or names none on a trip the user owns. Merely being able to open a trip of somebody else counts nothing. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of trips the user owns that are on no day or only on days without a date.',
inputSchema: { from: query.from, to: query.to, type: query.type },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async getTourActivityStats(input: TourActivityStatsQuery, ctx: McpContext) {
return ok(answer(await this.stats.stats(ctx.userId), input));
}
@Tool({
name: 'set_tour_activity_excluded',
description:
'Leave a tour out of the activity sums of the user (excluded true), such as a tour that fell through, or count it again (excluded false), once the user asked for it. place_id is the tour as get_tour_activity_stats lists it. Nothing of the tour, its days or its plan changes, and the sums of anybody else stay as they are. Answers the sums of all time as they are then.',
inputSchema: { place_id: exclusion.place_id, excluded: exclusion.excluded },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async setTourActivityExcluded(input: TourActivityExclusionRequest, ctx: McpContext) {
try {
return ok(answer(await this.stats.setExcluded(ctx.userId, input)));
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
}
@@ -0,0 +1,52 @@
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { Tours } from '../../db/entities/Tours.entity';
import type { TourActivitiesRepository } from '../../db/repositories/TourActivities.repository';
import type { TourActivityExclusionsRepository } from '../../db/repositories/TourActivityExclusions.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';
/**
* STA-02: the user's own tour activities for the Atlas, across every trip the
* user can open, and leaving one out of them. Reads only, apart from the
* user's own exclusions: no tour, day or plan changes here. Every answer is
* bounded by the trips the user owns or is a member of, read anew for each
* call; within them only the user's own part counts (see
* `buildActivityStats`). REST and MCP call these two methods.
*/
@Injectable()
export class TourActivityStatsService {
constructor(
private readonly membership: TripMembershipService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
@InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository,
@InjectRepository(TourActivityExclusions) private readonly exclusionsRepo: TourActivityExclusionsRepository,
) {}
async stats(userId: number): Promise<TourActivityStatsResponse> {
const tripIds = await this.membership.listAccessibleTripIds(userId);
const [executions, tracks, excluded] = await Promise.all([
this.activitiesRepo.listExecutionsForStats(tripIds, userId),
this.activitiesRepo.listTracksForStats(tripIds),
this.exclusionsRepo.listPlaceIds(userId),
]);
return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) });
}
/**
* Leaves a tour out of the user's sums, or counts it again, and answers the
* sums as they are then. The tour must be in a trip the user can open; any
* other id reads as missing. Only the user's own sums change.
*/
async setExcluded(userId: number, input: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> {
const tripIds = await this.membership.listAccessibleTripIds(userId);
if (!(await this.toursRepo.findForLibrary(tripIds, input.place_id))) throw new NotFoundException('Tour not found');
if (input.excluded) await this.exclusionsRepo.exclude(userId, input.place_id);
else await this.exclusionsRepo.include(userId, input.place_id);
return await this.stats(userId);
}
}
+11
View File
@@ -1,5 +1,6 @@
import { Places } from '../../db/entities/Places.entity';
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity';
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
import { TourSources } from '../../db/entities/TourSources.entity';
@@ -24,6 +25,9 @@ import { CityWalkMcp } from './city-walk.mcp';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
import { TourActivityStatsController } from './tour-activity-stats.controller';
import { TourActivityStatsMcp } from './tour-activity-stats.mcp';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { TourChangePreviewService } from './tour-change-preview.service';
import { TourChangeSaveService } from './tour-change-save.service';
import { TourChangeUndoMcp } from './tour-change-undo.mcp';
@@ -76,6 +80,9 @@ import { Module } from '@nestjs/common';
* step for a later undo (MCP-T03). The recorded tracks assigned to a tour
* (ACT-01) are read with its detail and written through
* TourActivitiesService, over the day assignments the visits repository reads.
* The user's own activity sums across their trips (STA-02) are read by
* TourActivityStatsService, over the trips TripMembershipModule says they can
* open, with the tours the user left out of them.
*/
@Module({
imports: [
@@ -106,6 +113,7 @@ import { Module } from '@nestjs/common';
TourStationVisits,
TourChangeSteps,
TourActivities,
TourActivityExclusions,
Places,
]),
],
@@ -113,6 +121,7 @@ import { Module } from '@nestjs/common';
TourHorseRoutesController,
TourTakeOverController,
TourActivitiesController,
TourActivityStatsController,
TourPoiSearchController,
TourSkiController,
TourTrailGradeController,
@@ -144,6 +153,7 @@ import { Module } from '@nestjs/common';
TourChangeSaveService,
TourChangeUndoService,
TourActivitiesService,
TourActivityStatsService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -151,6 +161,7 @@ import { Module } from '@nestjs/common';
TourChangeMcp,
TourChangeUndoMcp,
TourActivitiesMcp,
TourActivityStatsMcp,
],
})
export class ToursModule {}
@@ -0,0 +1,216 @@
/**
* STA-02 e2e: the user's own tour activities for the Atlas, through the real
* JwtAuthGuard and AddonGuard against a real migrated temp SQLite db. Tours
* are created through the tours API and put on dated days; whose they are
* follows the day's participants and the trip's owner, never mere access.
*
* Fixtures: user 1 owns trip 5 (two dated days, one without a date), user 2 is
* a member, user 3 has no access.
*/
import { db } from '../../src/db/database';
import { AssignmentParticipants } from '../../src/db/entities/AssignmentParticipants.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { TourActivityExclusions } from '../../src/db/entities/TourActivityExclusions.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { makeDayAssignment } from '../helpers/factories/itinerary';
import { countRows, deleteRows, insertRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const journeyHooks = {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
};
const realtime = new FakeRealtimeService();
const OWNER = 1;
const MEMBER = 2;
const OUTSIDER = 3;
const TRIP = 5;
const STATS = '/api/tours/activity-stats';
const tourBody = (name: string) => ({
name,
route_geometry: [
[47, 11, 600],
[47.01, 11.01, 700],
],
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
});
describe('Tour activity stats e2e (STA-02)', () => {
let orm: TestOrm;
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
/** A tour of trip 5 through the API, put on the given day. */
const tourOnDay = async (name: string, dayId: number | null) => {
const res = await request(server).post(`/api/trips/${TRIP}/tours`).set(as(OWNER)).send(tourBody(name));
expect(res.status).toBe(201);
const placeId = res.body.tour.place_id as number;
const assignmentId = dayId === null ? null : (await makeDayAssignment(orm, dayId, placeId)).id;
return { placeId, assignmentId };
};
const exclude = (body: unknown, userId = OWNER) =>
request(server).put(`${STATS}/exclusions`).set(as(userId)).send(body);
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 3, username: 'outsider', email: 'out@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: TRIP, user: 1, title: 'Alps' }]);
await addTripMember(orm, TRIP, 2);
await insertRows(orm, Days, [
{ id: 3, trip: TRIP, day_number: 1, date: '2026-07-02' },
{ id: 4, trip: TRIP, day_number: 2, date: '2026-07-03' },
{ id: 6, trip: TRIP, day_number: 3 },
]);
app = await build();
server = app.getHttpServer();
});
beforeEach(async () => {
await setAddonEnabled(orm, 'tours', true);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('STA2-E2E-001: the owner gets the planned tours of dated days, on the plan, and what cannot count', async () => {
const ridge = await tourOnDay('Ridge', 3);
await tourOnDay('Undated', 6);
await tourOnDay('Idle', null);
const res = await request(server).get(STATS).set(as(OWNER));
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([
expect.objectContaining({
place_id: ridge.placeId,
name: 'Ridge',
tour_type: 'hike',
trip: { id: TRIP, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
excluded: false,
}),
]);
expect(res.body.uncounted).toEqual({ unassigned: 1, undated: 1 });
});
it('STA2-E2E-002: a member counts only the days naming them; an outsider gets nothing', async () => {
const ridge = await tourOnDay('Ridge', 3);
const lake = await tourOnDay('Lake', 4);
await insertRow(orm, AssignmentParticipants, { assignment: lake.assignmentId, user: MEMBER });
const member = await request(server).get(STATS).set(as(MEMBER));
expect(member.body.entries.map((entry: { place_id: number }) => entry.place_id)).toEqual([lake.placeId]);
expect(member.body.uncounted).toEqual({ unassigned: 0, undated: 0 });
// The owner is not on Lake's day any more, only on Ridge's.
const owner = await request(server).get(STATS).set(as(OWNER));
expect(owner.body.entries.map((entry: { place_id: number }) => entry.place_id)).toEqual([ridge.placeId]);
const outsider = await request(server).get(STATS).set(as(OUTSIDER));
expect(outsider.body).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 } });
});
it('STA2-E2E-003: leaving a tour out flags it for the user alone and counts it again on request', async () => {
const ridge = await tourOnDay('Ridge', 3);
await insertRow(orm, AssignmentParticipants, { assignment: ridge.assignmentId, user: MEMBER });
await insertRow(orm, AssignmentParticipants, { assignment: ridge.assignmentId, user: OWNER });
const left = await exclude({ place_id: ridge.placeId, excluded: true });
expect(left.status).toBe(200);
expect(left.body.entries[0]).toMatchObject({ place_id: ridge.placeId, excluded: true });
const member = await request(server).get(STATS).set(as(MEMBER));
expect(member.body.entries[0]).toMatchObject({ place_id: ridge.placeId, excluded: false });
const back = await exclude({ place_id: ridge.placeId, excluded: false });
expect(back.body.entries[0]).toMatchObject({ excluded: false });
expect(await countRows(orm, TourActivityExclusions)).toBe(0);
});
it('STA2-E2E-004: the gates: 401 without a session, 404 without access or the addon, 400 on a bad body', async () => {
const ridge = await tourOnDay('Ridge', 3);
expect((await request(server).get(STATS)).status).toBe(401);
const body = { place_id: ridge.placeId, excluded: true };
const anonymous = await request(server).put(`${STATS}/exclusions`).send(body);
expect(anonymous.status).toBe(401);
const foreign = await exclude({ place_id: ridge.placeId, excluded: true }, OUTSIDER);
expect(foreign.status).toBe(404);
expect((await exclude({ place_id: ridge.placeId })).status).toBe(400);
expect((await exclude({ place_id: ridge.placeId, excluded: true, note: 'rain' })).status).toBe(400);
expect(await countRows(orm, TourActivityExclusions)).toBe(0);
await setAddonEnabled(orm, 'tours', false);
const off = await request(server).get(STATS).set(as(OWNER));
expect(off.status).toBe(404);
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
});
});
+4 -3
View File
@@ -42,14 +42,15 @@ describe('ALL_ENTITIES', () => {
* + paddle_water_graphs and paddle_region_requests, a region's water network and the regions that wait for an admin
* + tour_station_visits, how far a city walk got on one of its days
* + tour_change_steps, the changes to a tour saved from a confirmed proposal
* + tour_activities, the recorded tracks assigned to a tour)
* + tour_activities, the recorded tracks assigned to a tour
* + tour_activity_exclusions, the tours a user left out of their own activity sums)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 145 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(145);
it('ENT-003: exactly 146 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(146);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -39,7 +39,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + tour_change_steps: id, place_id, proposal_id, base_revision, revision, changes, before_state (a step is
// only ever written whole, by the save of a confirmed change).
// + tour_activities: id, place_id, track_place_id (an assignment always names its tour and its recording).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 507;
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 510;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -73,6 +73,7 @@ const UNNUMBERED = [
'Migration20200101045700_a_tour_records_the_changes_saved_from_a_proposal',
'Migration20200101045710_a_saved_tour_change_can_be_taken_back',
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,130 @@
/**
* `TourActivitiesRepository`, the STA-02 reads: every tour of the trips the
* user can open, once per day it is on, with the day's date, the trip's owner
* and who the day names as taking part; and the recordings assigned to them
* with their lines. Seeded through the ORM factories over the migrated
* snapshot schema.
*/
import { AssignmentParticipants } from '../../../../src/db/entities/AssignmentParticipants.entity';
import { TourActivities } from '../../../../src/db/entities/TourActivities.entity';
import type { TourActivitiesRepository } from '../../../../src/db/repositories/TourActivities.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makeDayAssignment } from '../../../helpers/factories/itinerary';
import { makePlace } from '../../../helpers/factories/places';
import { insertRow } from '../../../helpers/factories/rows';
import { makeTour, makeTourActivity } from '../../../helpers/factories/tours';
import { makeDay, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourActivitiesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourActivities);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
]);
/** A hike on a dated day with a friend named on it, a tour on no day, a recording, and a trip of somebody else. */
async function fixture() {
const { user } = await makeUser(t);
const { user: friend } = await makeUser(t);
const trip = await makeTrip(t, user.id, { title: 'Alps' });
const day = await makeDay(t, trip.id, { date: '2026-07-02' });
const undated = await makeDay(t, trip.id);
const hike = await makePlace(t, trip.id, { name: 'Ridge' });
await makeTour(t, hike.id, { distance: 12.5, elevation_gain: 800, elevation_loss: 790 });
const onDay = await makeDayAssignment(t, day.id, hike.id);
const onUndated = await makeDayAssignment(t, undated.id, hike.id);
await insertRow(t, AssignmentParticipants, { assignment: onDay.id, user: friend.id });
const idle = await makePlace(t, trip.id, { name: 'Idle' });
await makeTour(t, idle.id);
const watch = await makePlace(t, trip.id, { name: 'Watch export', route_geometry: LINE });
await makeTour(t, watch.id, { origin: 'gpx' });
const theirs = await makeTrip(t, friend.id, { title: 'Theirs' });
const theirTour = await makePlace(t, theirs.id, { name: 'Their tour' });
await makeTour(t, theirTour.id);
return { user, friend, trip, day, hike, onDay, onUndated, idle, watch, theirs, theirTour };
}
describe('TourActivitiesRepository stats reads (STA-02)', () => {
it('STA2-REPO-010: lists each tour once per day it is on, once without a day, with the day and the trip', async () => {
const { user, friend, trip, hike, onDay, onUndated, idle, watch } = await fixture();
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.map((row) => [row.place_id, row.assignment_id, row.date])).toEqual([
[hike.id, onDay.id, '2026-07-02'],
[hike.id, onUndated.id, null],
[idle.id, null, null],
[watch.id, null, null],
]);
expect(rows[0]).toMatchObject({
name: 'Ridge',
tour_type: 'hike',
distance: 12.5,
elevation_gain: 800,
elevation_loss: 790,
derived_from_place_id: null,
trip_id: trip.id,
trip_title: 'Alps',
trip_owner_id: user.id,
participant_count: 1,
joined: 0,
});
expect(rows[1]).toMatchObject({ participant_count: 0, joined: 0 });
const asFriend = await repo.listExecutionsForStats([trip.id], friend.id);
expect(asFriend[0]).toMatchObject({ participant_count: 1, joined: 1 });
});
it('STA2-REPO-011: never reaches a trip outside the ids given, and reads nothing for none', async () => {
const { user, trip, theirs, theirTour } = await fixture();
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.some((row) => row.place_id === theirTour.id)).toBe(false);
expect((await repo.listExecutionsForStats([theirs.id], user.id)).map((row) => row.place_id)).toEqual([
theirTour.id,
]);
expect(await repo.listExecutionsForStats([], user.id)).toEqual([]);
});
it('STA2-REPO-012: a day of another trip gives no date', async () => {
const { user, trip, theirs, idle } = await fixture();
const foreignDay = await makeDay(t, theirs.id, { date: '2026-08-01' });
await makeDayAssignment(t, foreignDay.id, idle.id);
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.filter((row) => row.place_id === idle.id).map((row) => row.date)).toEqual([null]);
});
it('STA2-REPO-013: lists the recordings of the trips with the lines they were recorded with', async () => {
const { trip, hike, onDay, watch, theirs } = await fixture();
await makeTourActivity(t, hike.id, watch.id, { assignment: onDay.id });
expect(await repo.listTracksForStats([trip.id])).toEqual([
{
place_id: hike.id,
track_place_id: watch.id,
assignment_id: onDay.id,
track_name: 'Watch export',
track_route_geometry: LINE,
track_recorded_route_geometry: null,
},
]);
expect(await repo.listTracksForStats([theirs.id])).toEqual([]);
expect(await repo.listTracksForStats([])).toEqual([]);
});
});
@@ -0,0 +1,89 @@
/**
* `TourActivityExclusionsRepository` (STA-02): the tours a user left out of
* their own activity sums. Personal throughout: one user's rows never reach
* another's reads or writes. Seeded through the ORM factories over the
* migrated snapshot schema.
*/
import { Places } from '../../../../src/db/entities/Places.entity';
import { TourActivityExclusions } from '../../../../src/db/entities/TourActivityExclusions.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { TourActivityExclusionsRepository } from '../../../../src/db/repositories/TourActivityExclusions.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makePlace } from '../../../helpers/factories/places';
import { countRows, deleteRows, findRow } from '../../../helpers/factories/rows';
import { makeTour } from '../../../helpers/factories/tours';
import { makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourActivityExclusionsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourActivityExclusions);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** Two users, one trip with two tours. */
async function fixture() {
const { user } = await makeUser(t);
const { user: friend } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const ridge = await makePlace(t, trip.id, { name: 'Ridge' });
await makeTour(t, ridge.id);
const lake = await makePlace(t, trip.id, { name: 'Lake' });
await makeTour(t, lake.id, { tourTypeRef: 'kayak' });
return { user, friend, ridge, lake };
}
describe('TourActivityExclusionsRepository', () => {
it('STA2-REPO-001: exclude writes one row per user and tour, a second call changes nothing', async () => {
const { user, ridge, lake } = await fixture();
await repo.exclude(user.id, lake.id);
await repo.exclude(user.id, ridge.id);
await repo.exclude(user.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([ridge.id, lake.id].sort((a, b) => a - b));
expect(await countRows(t, TourActivityExclusions, { user: user.id })).toBe(2);
expect((await findRow(t, TourActivityExclusions, { user: user.id, place: ridge.id }))?.excluded_at).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
);
});
it('STA2-REPO-002: one user leaving a tour out leaves it in for everybody else', async () => {
const { user, friend, ridge } = await fixture();
await repo.exclude(user.id, ridge.id);
expect(await repo.listPlaceIds(friend.id)).toEqual([]);
await repo.include(friend.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([ridge.id]);
});
it('STA2-REPO-003: include counts the tour again, and is a no-op for a tour never left out', async () => {
const { user, ridge, lake } = await fixture();
await repo.exclude(user.id, ridge.id);
await repo.include(user.id, lake.id);
await repo.include(user.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([]);
});
it('STA2-REPO-004: deleting the tour or the user takes the row along', async () => {
const { user, friend, ridge, lake } = await fixture();
await repo.exclude(user.id, ridge.id);
await repo.exclude(friend.id, lake.id);
await deleteRows(t, Places, { id: ridge.id });
expect(await repo.listPlaceIds(user.id)).toEqual([]);
await deleteRows(t, Users, { id: friend.id });
expect(await countRows(t, TourActivityExclusions, {})).toBe(0);
});
});
@@ -0,0 +1,64 @@
/**
* TourActivityStatsController (STA-02): route metadata and delegation. The
* sums and the access check live in TourActivityStatsService, which the MCP
* tools call too; the service suite and the e2e suite hold those.
*/
import 'reflect-metadata';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { TourActivityStatsController } from '../../../src/nest/tours/tour-activity-stats.controller';
import type { TourActivityStatsService } from '../../../src/nest/tours/tour-activity-stats.service';
import type { User } from '../../../src/types';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { tourActivityExclusionRequestSchema } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/tours/tour-activity-stats.service', () => ({ TourActivityStatsService: class {} }));
vi.mock('../../../src/nest/auth-core/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
const answer = { entries: [], uncounted: { unassigned: 0, undated: 0 } };
describe('TourActivityStatsController', () => {
it('STA2-CTL-001: sits behind the addon and auth guards, not under a trip', () => {
expect(Reflect.getMetadata(PATH_METADATA, TourActivityStatsController)).toBe('api/tours/activity-stats');
expect(Reflect.getMetadata(GUARDS_METADATA, TourActivityStatsController)).toEqual([AddonGuard, JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, TourActivityStatsController)).toEqual({
addonId: 'tours',
label: 'Tours',
});
});
it('STA2-CTL-002: GET reads the sums and PUT exclusions writes, both answering 200 without a trip permission', () => {
const routes = [
['list', RequestMethod.GET, '/'],
['setExcluded', RequestMethod.PUT, 'exclusions'],
] as const;
for (const [handler, method, path] of routes) {
const fn = TourActivityStatsController.prototype[handler];
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(HTTP_CODE_METADATA, fn)).toBeUndefined();
}
});
it('STA2-CTL-003: GET hands the user to the service', async () => {
const service = { stats: vi.fn().mockResolvedValue(answer) } as unknown as TourActivityStatsService;
expect(await new TourActivityStatsController(service).list({ id: 3 } as User)).toBe(answer);
expect(service.stats).toHaveBeenCalledWith(3);
});
it('STA2-CTL-004: PUT hands the user and the validated body to the service', async () => {
const service = { setExcluded: vi.fn().mockResolvedValue(answer) } as unknown as TourActivityStatsService;
const body = tourActivityExclusionRequestSchema.parse({ place_id: 9, excluded: true });
expect(await new TourActivityStatsController(service).setExcluded({ id: 3 } as User, body)).toBe(answer);
expect(service.setExcluded).toHaveBeenCalledWith(3, body);
});
});
@@ -0,0 +1,223 @@
/**
* STA-02, the pure half of the user's activity sums: whose tours count, on
* which day, on what basis, and what is never added up twice.
*/
import type { TourStatsExecutionRow, TourStatsTrackRow } from '../../../src/db/repositories/TourActivities.repository';
import { buildActivityStats, isOwnExecution } from '../../../src/nest/tours/tour-activity-stats.helpers';
import { describe, expect, it } from 'vitest';
const ME = 1;
const FRIEND = 2;
function row(overrides: Partial<TourStatsExecutionRow> = {}): TourStatsExecutionRow {
return {
place_id: 10,
name: 'Ridge',
tour_type: 'hike',
distance: 12.5,
elevation_gain: 800,
elevation_loss: 790,
derived_from_place_id: null,
trip_id: 5,
trip_title: 'Alps',
trip_owner_id: ME,
assignment_id: 100,
date: '2026-07-02',
participant_count: 0,
joined: 0,
...overrides,
};
}
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
[47.02, 11, 650],
]);
function track(overrides: Partial<TourStatsTrackRow> = {}): TourStatsTrackRow {
return {
place_id: 10,
track_place_id: 20,
assignment_id: 100,
track_name: 'Watch export',
track_route_geometry: LINE,
track_recorded_route_geometry: null,
...overrides,
};
}
function stats(
executions: TourStatsExecutionRow[],
tracks: TourStatsTrackRow[] = [],
excluded: number[] = [],
userId = ME,
) {
return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) });
}
describe('isOwnExecution (STA-02)', () => {
it('STA2-HELP-001: a day naming the user is theirs, a day naming nobody is the owner only', () => {
expect(isOwnExecution(row({ joined: 1, participant_count: 2, trip_owner_id: FRIEND }), ME)).toBe(true);
expect(isOwnExecution(row({ participant_count: 0 }), ME)).toBe(true);
expect(isOwnExecution(row({ participant_count: 0, trip_owner_id: FRIEND }), ME)).toBe(false);
expect(isOwnExecution(row({ participant_count: 1, joined: 0 }), ME)).toBe(false);
});
});
describe('buildActivityStats (STA-02)', () => {
it('STA2-HELP-002: a planned tour on a dated day of the own trip counts with its plan', () => {
expect(stats([row()])).toEqual({
entries: [
{
place_id: 10,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12.5,
ascent_m: 800,
descent_m: 790,
excluded: false,
},
],
uncounted: { unassigned: 0, undated: 0 },
});
});
it('STA2-HELP-003: a tour on several days counts once, on its first day, saying on how many', () => {
const { entries } = stats([
row({ assignment_id: 101, date: '2026-07-04' }),
row({ assignment_id: 100, date: '2026-07-02' }),
row({ assignment_id: 102, date: null }),
]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ date: '2026-07-02', day_count: 2 });
});
it('STA2-HELP-004: a linked recording replaces the plan and is no tour of its own', () => {
const { entries } = stats(
[row(), row({ place_id: 20, name: 'Watch export', assignment_id: null, date: null })],
[track()],
);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
place_id: 10,
basis: 'track',
track: { place_id: 20, name: 'Watch export' },
date: '2026-07-02',
day_count: 1,
ascent_m: 100,
descent_m: 50,
});
expect(entries[0]?.distance_km).toBeCloseTo(2.22, 1);
});
it('STA2-HELP-005: a recording on no day counts on the first day; one recorded per day counts per day', () => {
const executions = [row({ assignment_id: 100 }), row({ assignment_id: 101, date: '2026-07-03' })];
const noDay = stats(executions, [track({ assignment_id: null })]);
expect(noDay.entries.map((entry) => entry.date)).toEqual(['2026-07-02']);
const twice = stats(executions, [track(), track({ track_place_id: 21, assignment_id: 101 })]);
expect(twice.entries.map((entry) => [entry.date, entry.track?.place_id])).toEqual([
['2026-07-03', 21],
['2026-07-02', 20],
]);
});
it('STA2-HELP-006: a recording is measured on the line it kept when it was planned over since', () => {
const kept = JSON.stringify([
[47, 11],
[47.1, 11],
]);
const { entries } = stats([row()], [track({ track_recorded_route_geometry: kept })]);
expect(entries[0]?.distance_km).toBeCloseTo(11.1, 0);
expect(entries[0]).toMatchObject({ ascent_m: null, descent_m: null });
});
it('STA2-HELP-007: a recording without a line knows no distance', () => {
const { entries } = stats([row()], [track({ track_route_geometry: null })]);
expect(entries[0]).toMatchObject({ basis: 'track', distance_km: null, ascent_m: null });
});
it('STA2-HELP-008: the recording of somebody else’s day is theirs, so the plan stands for the user', () => {
const executions = [
row({ assignment_id: 100, participant_count: 0 }),
row({ assignment_id: 101, date: '2026-07-03', participant_count: 1, joined: 0 }),
];
const { entries } = stats(executions, [track({ assignment_id: 101 })]);
expect(entries).toEqual([expect.objectContaining({ basis: 'plan', date: '2026-07-02' })]);
});
it('STA2-HELP-009: access to somebody else’s trip counts nothing; a day naming the user counts', () => {
const foreign = { trip_owner_id: FRIEND, trip_id: 6, trip_title: 'Theirs' };
expect(stats([row(foreign)]).entries).toEqual([]);
expect(stats([row({ ...foreign, participant_count: 2, joined: 1 })]).entries).toHaveLength(1);
expect(stats([row(foreign)]).uncounted).toEqual({ unassigned: 0, undated: 0 });
});
it('STA2-HELP-010: a day naming only others leaves the owner out, and says nothing about it', () => {
expect(stats([row({ participant_count: 1, joined: 0 })])).toEqual({
entries: [],
uncounted: { unassigned: 0, undated: 0 },
});
});
it('STA2-HELP-011: own tours on no day or only on undated days are named as not counting', () => {
const result = stats([
row({ place_id: 11, assignment_id: null, date: null }),
row({ place_id: 12, assignment_id: 103, date: null }),
row({ place_id: 13, assignment_id: 104, date: 'not a date' }),
]);
expect(result).toEqual({ entries: [], uncounted: { unassigned: 1, undated: 2 } });
});
it('STA2-HELP-012: a whole route does not count beside the stages planned from it', () => {
const route = row({ place_id: 30, name: 'Whole route', distance: 60, assignment_id: 300, date: '2026-07-01' });
const stage1 = row({ place_id: 31, name: 'Stage 1', distance: 20, derived_from_place_id: 30, assignment_id: 301 });
const stage2 = row({
place_id: 32,
name: 'Stage 2',
distance: 40,
derived_from_place_id: 30,
assignment_id: 302,
date: '2026-07-03',
});
expect(stats([route, stage1, stage2]).entries.map((entry) => entry.place_id)).toEqual([32, 31]);
// With no stage on a day, the route is what was done.
const unplanned = row({ ...stage1, assignment_id: null, date: null });
expect(stats([route, unplanned]).entries.map((entry) => entry.place_id)).toEqual([30]);
});
it('STA2-HELP-013: a paddle tour carries no heights, planned or recorded', () => {
const paddle = row({ tour_type: 'kayak', elevation_gain: 12, elevation_loss: 12 });
expect(stats([paddle]).entries[0]).toMatchObject({ ascent_m: null, descent_m: null, distance_km: 12.5 });
expect(stats([paddle], [track()]).entries[0]).toMatchObject({ ascent_m: null, descent_m: null });
});
it('STA2-HELP-014: an unknown or broken measure stays unknown, never zero or negative', () => {
const { entries } = stats([row({ distance: null, elevation_gain: -5, elevation_loss: Number.NaN })]);
expect(entries[0]).toMatchObject({ distance_km: null, ascent_m: null, descent_m: null });
});
it('STA2-HELP-015: an excluded tour stays listed, flagged, with every entry of it', () => {
const { entries } = stats([row()], [track()], [10]);
expect(entries).toEqual([expect.objectContaining({ place_id: 10, excluded: true, basis: 'track' })]);
});
it('STA2-HELP-016: the newest first, then by name; a timestamp on a day reads as its calendar day', () => {
const { entries } = stats([
row({ place_id: 1, name: 'Zugspitze', assignment_id: 1, date: '2026-07-02 00:00:00' }),
row({ place_id: 2, name: 'Alpspitze', assignment_id: 2, date: '2026-07-02' }),
row({ place_id: 3, name: 'Hochkalter', assignment_id: 3, date: '2026-08-01' }),
]);
expect(entries.map((entry) => [entry.name, entry.date])).toEqual([
['Hochkalter', '2026-08-01'],
['Alpspitze', '2026-07-02'],
['Zugspitze', '2026-07-02'],
]);
});
});
@@ -0,0 +1,121 @@
/**
* TourActivityStatsMcp (STA-02): the user's own tour activities and leaving
* one out, over MCP, through the same service as the Atlas: the sums of a
* period and a type, the tours they are made of and what they rest on. Today
* is pinned, so a tour's day is over or not whenever the suite runs.
*/
import { TourActivityExclusions } from '../../../src/db/entities/TourActivityExclusions.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { TourActivityStatsMcp } from '../../../src/nest/tours/tour-activity-stats.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
import { countRows } from '../../helpers/factories/rows';
import { makeTour } from '../../helpers/factories/tours';
import { makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let mcp: TourActivityStatsMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(TourActivityStatsMcp);
});
beforeEach(() => {
resetTestDb(db);
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-07-10T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
const text = result.content[0]?.text ?? '';
return result.isError ? { error: text } : JSON.parse(text);
}
/** A hike and a paddle tour done in July, a hike still ahead, and a trip somebody else owns. */
async function fixture() {
const { user } = await makeUser(orm());
const { user: stranger } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const tours: Record<string, number> = {};
for (const [name, type, distance, date] of [
['Ridge', 'hike', 12, '2026-07-02'],
['Lake', 'kayak', 9, '2026-07-04'],
['Summit', 'hike', 15, '2026-07-20'],
] as const) {
const day = await makeDay(orm(), trip.id, { date });
const place = await makePlace(orm(), trip.id, { name });
await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 });
await makeDayAssignment(orm(), day.id, place.id);
tours[name] = place.id;
}
const closed = await makeTrip(orm(), stranger.id);
const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' });
await makeTour(orm(), hidden.id);
return { user, tours, hidden };
}
describe('TourActivityStatsMcp', () => {
it('STA2-MCP-001: get_tour_activity_stats sums the done tours, by type, and says what is still ahead', async () => {
const { user, tours } = await fixture();
const result = read(await mcp.getTourActivityStats({}, ctx(user.id)));
expect(result.today).toBe('2026-07-10');
expect(result.totals).toEqual({
count: 2,
distanceKm: { value: 21, known: 2, of: 2 },
ascentM: { value: 500, known: 1, of: 1 },
descentM: { value: 500, known: 1, of: 1 },
byBasis: { plan: 2, track: 0 },
});
expect(result.by_type.map((type: { type: string }) => type.type)).toEqual(['hike', 'kayak']);
expect(result.tours.map((tour: { place_id: number; basis: string }) => [tour.place_id, tour.basis])).toEqual([
[tours.Lake, 'plan'],
[tours.Ridge, 'plan'],
]);
expect(result.upcoming).toBe(1);
expect(result.uncounted).toEqual({ unassigned: 0, undated: 0 });
});
it('STA2-MCP-002: narrows by period and type', async () => {
const { user, tours } = await fixture();
const paddled = read(await mcp.getTourActivityStats({ type: 'kayak' }, ctx(user.id)));
expect(paddled.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([tours.Lake]);
const early = read(await mcp.getTourActivityStats({ from: '2026-07-01', to: '2026-07-03' }, ctx(user.id)));
expect(early.totals.count).toBe(1);
expect(early.upcoming).toBe(0);
});
it('STA2-MCP-003: set_tour_activity_excluded leaves a tour out and counts it again', async () => {
const { user, tours } = await fixture();
const left = read(await mcp.setTourActivityExcluded({ place_id: tours.Ridge, excluded: true }, ctx(user.id)));
expect(left.totals.count).toBe(1);
expect(left.tours.find((tour: { place_id: number }) => tour.place_id === tours.Ridge)?.excluded).toBe(true);
const back = read(await mcp.setTourActivityExcluded({ place_id: tours.Ridge, excluded: false }, ctx(user.id)));
expect(back.totals.count).toBe(2);
expect(await countRows(orm(), TourActivityExclusions)).toBe(0);
});
it('STA2-MCP-004: a tour of a trip the user cannot open is not found, as over REST', async () => {
const { user, hidden } = await fixture();
expect(read(await mcp.setTourActivityExcluded({ place_id: hidden.id, excluded: true }, ctx(user.id)))).toEqual({
error: 'Tour not found.',
});
expect(await countRows(orm(), TourActivityExclusions)).toBe(0);
});
});
@@ -0,0 +1,152 @@
/**
* TourActivityStatsService (STA-02): the user's own tour activities across
* every trip they can open, and leaving one out of them. The service comes out
* of ToursModule over a snapshot test db; rows are seeded through the ORM
* factories.
*/
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
import { TourActivityExclusions } from '../../../src/db/entities/TourActivityExclusions.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { TourActivityStatsService } from '../../../src/nest/tours/tour-activity-stats.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
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 { addTripMember, makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { NotFoundException } from '@nestjs/common';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let service: TourActivityStatsService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
service = t.get(TourActivityStatsService);
});
beforeEach(() => resetTestDb(db));
afterAll(async () => {
await t.close();
db.close();
});
const RECORDED = JSON.stringify([
[47, 11, 600],
[47.01, 11.005, 760],
[47.02, 11, 880],
]);
/**
* The user's trip with a hike on a day and a paddle tour recorded on another;
* a trip of a friend the user is a member of, with one tour the friend did
* alone and one the user is named on; and a trip the user cannot open.
*/
async function fixture() {
const { user } = await makeUser(orm());
const { user: friend } = await makeUser(orm());
const { user: stranger } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const day1 = await makeDay(orm(), trip.id, { date: '2026-07-02' });
const day2 = await makeDay(orm(), trip.id, { date: '2026-07-03' });
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
await makeTour(orm(), hike.id, { distance: 12.5, elevation_gain: 800, elevation_loss: 790 });
await makeDayAssignment(orm(), day1.id, hike.id);
const paddle = await makePlace(orm(), trip.id, { name: 'Lake' });
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', distance: 9 });
const onDay2 = await makeDayAssignment(orm(), day2.id, paddle.id);
const watch = await makePlace(orm(), trip.id, { name: 'Watch export', route_geometry: RECORDED });
await makeTour(orm(), watch.id, { origin: 'gpx' });
await makeTourActivity(orm(), paddle.id, watch.id, { assignment: onDay2.id });
const idle = await makePlace(orm(), trip.id, { name: 'Idle' });
await makeTour(orm(), idle.id);
const theirs = await makeTrip(orm(), friend.id, { title: 'Theirs' });
await addTripMember(orm(), theirs.id, user.id);
const theirDay = await makeDay(orm(), theirs.id, { date: '2026-08-10' });
const alone = await makePlace(orm(), theirs.id, { name: 'Alone' });
await makeTour(orm(), alone.id, { distance: 30 });
await makeDayAssignment(orm(), theirDay.id, alone.id);
const together = await makePlace(orm(), theirs.id, { name: 'Together' });
await makeTour(orm(), together.id, { distance: 7 });
const togetherOn = await makeDayAssignment(orm(), theirDay.id, together.id);
await insertRow(orm(), AssignmentParticipants, { assignment: togetherOn.id, user: user.id });
const closed = await makeTrip(orm(), stranger.id);
const closedDay = await makeDay(orm(), closed.id, { date: '2026-07-01' });
const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' });
await makeTour(orm(), hidden.id);
await makeDayAssignment(orm(), closedDay.id, hidden.id);
return { user, friend, hike, paddle, watch, alone, together, togetherOn, hidden };
}
describe('TourActivityStatsService', () => {
it('STA2-SVC-001: lists the own tours of every trip the user can open, plan or recording, newest first', async () => {
const { user, hike, paddle, watch, together } = await fixture();
const result = await service.stats(user.id);
expect(result.entries.map((entry) => [entry.place_id, entry.basis, entry.date])).toEqual([
[together.id, 'plan', '2026-08-10'],
[paddle.id, 'track', '2026-07-03'],
[hike.id, 'plan', '2026-07-02'],
]);
expect(result.entries[1]).toMatchObject({
tour_type: 'kayak',
trip: { title: 'Alps' },
track: { place_id: watch.id, name: 'Watch export' },
ascent_m: null,
descent_m: null,
});
expect(result.entries[1]?.distance_km).toBeCloseTo(2.35, 1);
expect(result.uncounted).toEqual({ unassigned: 1, undated: 0 });
});
it('STA2-SVC-002: the friend sees the tour they did alone, and not the user’s trip they cannot open', async () => {
const { friend, alone, together } = await fixture();
const result = await service.stats(friend.id);
// Together names only the user, so it is not the friend's, owner or not.
expect(result.entries.map((entry) => entry.place_id)).toEqual([alone.id]);
expect(result.entries.some((entry) => entry.place_id === together.id)).toBe(false);
});
it('STA2-SVC-003: leaving a tour out keeps it listed, flagged; counting it again clears the flag', async () => {
const { user, hike } = await fixture();
const left = await service.setExcluded(user.id, { place_id: hike.id, excluded: true });
expect(left.entries.find((entry) => entry.place_id === hike.id)?.excluded).toBe(true);
expect(await findRow(orm(), Tours, { place: hike.id })).toMatchObject({ distance: 12.5 });
const back = await service.setExcluded(user.id, { place_id: hike.id, excluded: false });
expect(back.entries.find((entry) => entry.place_id === hike.id)?.excluded).toBe(false);
expect(await countRows(orm(), TourActivityExclusions, {})).toBe(0);
});
it('STA2-SVC-004: a tour of a trip the user cannot open reads as missing and nothing is written', async () => {
const { user, hidden } = await fixture();
await expect(service.setExcluded(user.id, { place_id: hidden.id, excluded: true })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.setExcluded(user.id, { place_id: 999_999, excluded: true })).rejects.toBeInstanceOf(
NotFoundException,
);
expect(await countRows(orm(), TourActivityExclusions, {})).toBe(0);
});
it('STA2-SVC-005: one user leaving a tour out changes nobody else’s list', async () => {
const { user, friend, together, togetherOn } = await fixture();
await insertRow(orm(), AssignmentParticipants, { assignment: togetherOn.id, user: friend.id });
await service.setExcluded(user.id, { place_id: together.id, excluded: true });
const friends = await service.stats(friend.id);
expect(friends.entries.find((entry) => entry.place_id === together.id)?.excluded).toBe(false);
});
it('STA2-SVC-006: a user without trips has nothing to count', async () => {
const { user } = await makeUser(orm());
expect(await service.stats(user.id)).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 } });
});
});