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();
}
}