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
@@ -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 {}