diff --git a/client/src/components/Atlas/tourActivity/AtlasTourActivityPane.test.tsx b/client/src/components/Atlas/tourActivity/AtlasTourActivityPane.test.tsx index 86e212184..09ed42f71 100644 --- a/client/src/components/Atlas/tourActivity/AtlasTourActivityPane.test.tsx +++ b/client/src/components/Atlas/tourActivity/AtlasTourActivityPane.test.tsx @@ -28,6 +28,7 @@ function entry(overrides: Partial = {}): TourActivitySta name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, diff --git a/client/src/components/Atlas/tourActivity/TourActivityStatsDialog.test.tsx b/client/src/components/Atlas/tourActivity/TourActivityStatsDialog.test.tsx index a4ee825bc..91c365a0c 100644 --- a/client/src/components/Atlas/tourActivity/TourActivityStatsDialog.test.tsx +++ b/client/src/components/Atlas/tourActivity/TourActivityStatsDialog.test.tsx @@ -26,6 +26,7 @@ function entry(overrides: Partial = {}): TourActivitySta name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, diff --git a/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts b/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts index d70ce55ff..5c1e5cb1f 100644 --- a/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts +++ b/client/src/components/Atlas/tourActivity/tourActivityStatsModel.test.ts @@ -20,6 +20,7 @@ function entry(overrides: Partial = {}): TourActivitySta name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, diff --git a/client/src/components/Atlas/tourActivity/useTourActivityStats.test.ts b/client/src/components/Atlas/tourActivity/useTourActivityStats.test.ts index 75a022787..6e29d5b4d 100644 --- a/client/src/components/Atlas/tourActivity/useTourActivityStats.test.ts +++ b/client/src/components/Atlas/tourActivity/useTourActivityStats.test.ts @@ -21,6 +21,7 @@ function entry(overrides: Partial = {}): TourActivitySta name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, diff --git a/client/src/repo/tourActivityStatsRepo.test.ts b/client/src/repo/tourActivityStatsRepo.test.ts index ca2911029..e729bd68e 100644 --- a/client/src/repo/tourActivityStatsRepo.test.ts +++ b/client/src/repo/tourActivityStatsRepo.test.ts @@ -17,6 +17,7 @@ const stats: TourActivityStatsResponse = { name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, diff --git a/server/src/db/repositories/TourActivities.repository.ts b/server/src/db/repositories/TourActivities.repository.ts index 25afec38a..843bf38ea 100644 --- a/server/src/db/repositories/TourActivities.repository.ts +++ b/server/src/db/repositories/TourActivities.repository.ts @@ -36,9 +36,9 @@ export interface TourActivityRow extends TourActivityRefRow { /** * 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). + * sums need: its plan's measures, the import it was planned from, the day's + * date, 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; @@ -50,7 +50,6 @@ export interface TourStatsExecutionRow { 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; @@ -191,7 +190,6 @@ export class TourActivitiesRepository extends TrekRepository { '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 diff --git a/server/src/nest/tours/tour-activity-stats.controller.ts b/server/src/nest/tours/tour-activity-stats.controller.ts index 19967762e..487b471d9 100644 --- a/server/src/nest/tours/tour-activity-stats.controller.ts +++ b/server/src/nest/tours/tour-activity-stats.controller.ts @@ -14,7 +14,7 @@ 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 + * trips the user owns or is a member of. 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') diff --git a/server/src/nest/tours/tour-activity-stats.helpers.ts b/server/src/nest/tours/tour-activity-stats.helpers.ts index 880121eb1..9e9ee154f 100644 --- a/server/src/nest/tours/tour-activity-stats.helpers.ts +++ b/server/src/nest/tours/tour-activity-stats.helpers.ts @@ -3,7 +3,9 @@ * 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. + * in @trek/shared for the rules this follows. Whether an entry is done, and + * which whole route its counted stages stand in for, is up to that summary: + * both depend on the reader's today. */ import type { TourStatsExecutionRow, TourStatsTrackRow } from '../../db/repositories/TourActivities.repository'; import { computeTourMetrics, parseRouteGeometry } from './tours.helpers'; @@ -22,7 +24,6 @@ interface DatedExecution { } interface ActivityStatsInput { - userId: number; executions: readonly TourStatsExecutionRow[]; tracks: readonly TourStatsTrackRow[]; /** The tours the user left out of their own sums. */ @@ -31,12 +32,12 @@ interface ActivityStatsInput { /** * 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. + * part, or names nobody, which in the planner means everyone of the trip. The + * rows only hold trips the user owns or is a member of, so a day that names + * participants without the user is the only one that is not theirs. */ -export function isOwnExecution(row: TourStatsExecutionRow, userId: number): boolean { - if (row.joined === 1) return true; - return row.participant_count === 0 && row.trip_owner_id === userId; +export function isOwnExecution(row: TourStatsExecutionRow): boolean { + return row.joined === 1 || row.participant_count === 0; } /** A trip day's date as a calendar day, null when the day has none. */ @@ -56,8 +57,8 @@ function groupByTour(rows: readonly TourStatsExecutionRow[]): TourOfStats[] { } /** 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)); +function ownExecutions(tour: TourOfStats): { own: number; dated: DatedExecution[] } { + const own = tour.executions.filter(isOwnExecution); const dated: DatedExecution[] = []; for (const row of own) { const date = calendarDay(row.date); @@ -84,21 +85,25 @@ function baseEntry(tour: TourOfStats, excluded: ReadonlySet) { name: row.name, tour_type: row.tour_type as TourTypeKey, trip: { id: row.trip_id, title: row.trip_title }, + derived_from_place_id: row.derived_from_place_id, excluded: excluded.has(row.place_id), }; } -/** The plan standing in for the tour: once, on the first of its days, however many there are. */ +/** + * The plan standing in for the tour: once, however many days it is on, on the + * last of them, so a tour still under way does not count before it is done. + */ function planEntry( tour: TourOfStats, - first: DatedExecution, + last: DatedExecution, dayCount: number, excluded: ReadonlySet, ): TourActivityStatsEntry { const { row } = tour; return { ...baseEntry(tour, excluded), - date: first.date, + date: last.date, basis: 'plan', track: null, day_count: dayCount, @@ -129,55 +134,58 @@ function trackEntry( /** * 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. + * recording on no day stands for the whole tour, so it counts with the last 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[]], + dated: readonly DatedExecution[], + last: DatedExecution, tracks: readonly TourStatsTrackRow[], excluded: ReadonlySet, ): TourActivityStatsEntry[] { - const dateOf = new Map([first, ...rest].map((execution) => [execution.assignmentId, execution.date])); + const dateOf = new Map(dated.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) })) + .map((track) => ({ track, date: track.assignment_id === null ? last.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)]; + if (recorded.length === 0) return [planEntry(tour, last, dated.length, 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. */ +/** What became of one tour: its entries, or why a tour of the user's 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; + const { own, dated } = ownExecutions(tour); + const last = dated.at(-1); + if (last) return entriesOfTour(tour, dated, last, input.tracks, input.excluded); 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. + * The user's activity entries and what of their trips cannot count. A + * recording is never a tour of its own here. A whole route other tours were + * planned from keeps its entries and each stage names it in + * `derived_from_place_id`: the shared summary leaves the route out only while + * one of its stages counts, which takes the reader's today. A route whose + * stages are on the user's days is not named as uncounted either, since they + * stand in for it. */ 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( + const routesOfPlannedStages = 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; + else if (outcome !== null && !routesOfPlannedStages.has(tour.row.place_id)) 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 }; diff --git a/server/src/nest/tours/tour-activity-stats.mcp.ts b/server/src/nest/tours/tour-activity-stats.mcp.ts index 2a0305021..f2442148d 100644 --- a/server/src/nest/tours/tour-activity-stats.mcp.ts +++ b/server/src/nest/tours/tour-activity-stats.mcp.ts @@ -59,7 +59,7 @@ export class TourActivityStatsMcp { @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.', + '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 last day it is on is over (today is UTC here), and only where the user takes part: a day of the tour names the user among its participants, or names none, which means everyone of the trip. A day naming only others is theirs. 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 whole route stays out while a stage planned from it (derived_from_place_id) counts; 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 the user's trips that are on no day or only on the user's days without a date.', inputSchema: { from: query.from, to: query.to, type: query.type }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), diff --git a/server/src/nest/tours/tour-activity-stats.service.ts b/server/src/nest/tours/tour-activity-stats.service.ts index 1697873d6..5ced2dbe1 100644 --- a/server/src/nest/tours/tour-activity-stats.service.ts +++ b/server/src/nest/tours/tour-activity-stats.service.ts @@ -34,7 +34,7 @@ export class TourActivityStatsService { this.activitiesRepo.listTracksForStats(tripIds), this.exclusionsRepo.listPlaceIds(userId), ]); - return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) }); + return buildActivityStats({ executions, tracks, excluded: new Set(excluded) }); } /** diff --git a/server/tests/e2e/tours-activity-stats.e2e.test.ts b/server/tests/e2e/tours-activity-stats.e2e.test.ts index 2bc5509dd..7a7ee5685 100644 --- a/server/tests/e2e/tours-activity-stats.e2e.test.ts +++ b/server/tests/e2e/tours-activity-stats.e2e.test.ts @@ -2,7 +2,8 @@ * 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. + * follows the day's participants as the planner reads them: a day naming + * nobody is everyone's in the trip, a day naming people is only theirs. * * Fixtures: user 1 owns trip 5 (two dated days, one without a date), user 2 is * a member, user 3 has no access. @@ -163,15 +164,21 @@ describe('Tour activity stats e2e (STA-02)', () => { }), ]); expect(res.body.uncounted).toEqual({ unassigned: 1, undated: 1 }); + // The days name nobody, so they are the member's as much as the owner's. + const member = await request(server).get(STATS).set(as(MEMBER)); + expect(member.body).toEqual(res.body); }); - it('STA2-E2E-002: a member counts only the days naming them; an outsider gets nothing', async () => { + it('STA2-E2E-002: a day naming people counts only for them, one naming nobody for all; no outsider', 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.entries.map((entry: { place_id: number }) => entry.place_id)).toEqual([ + lake.placeId, + ridge.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)); @@ -213,4 +220,13 @@ describe('Tour activity stats e2e (STA-02)', () => { expect(off.status).toBe(404); expect(off.body).toEqual({ error: 'Tours addon is not enabled' }); }); + + it('STA2-E2E-005: a tour on two days counts once, dated on the last of them', async () => { + const ridge = await tourOnDay('Ridge', 3); + await makeDayAssignment(orm, 4, ridge.placeId); + const res = await request(server).get(STATS).set(as(OWNER)); + expect(res.body.entries).toEqual([ + expect.objectContaining({ place_id: ridge.placeId, date: '2026-07-03', day_count: 2 }), + ]); + }); }); diff --git a/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts b/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts index 1bd533605..8e9a1af17 100644 --- a/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts +++ b/server/tests/unit/db/repositories/TourActivities.stats.repository.test.ts @@ -84,7 +84,6 @@ describe('TourActivitiesRepository stats reads (STA-02)', () => { derived_from_place_id: null, trip_id: trip.id, trip_title: 'Alps', - trip_owner_id: user.id, participant_count: 1, joined: 0, }); diff --git a/server/tests/unit/nest/tour-activity-stats.helpers.test.ts b/server/tests/unit/nest/tour-activity-stats.helpers.test.ts index d96a44115..fdcdb4daa 100644 --- a/server/tests/unit/nest/tour-activity-stats.helpers.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.helpers.test.ts @@ -7,9 +7,6 @@ import { buildActivityStats, isOwnExecution } from '../../../src/nest/tours/tour import { describe, expect, it } from 'vitest'; -const ME = 1; -const FRIEND = 2; - function row(overrides: Partial = {}): TourStatsExecutionRow { return { place_id: 10, @@ -21,7 +18,6 @@ function row(overrides: Partial = {}): TourStatsExecution 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, @@ -48,21 +44,15 @@ function track(overrides: Partial = {}): TourStatsTrackRow { }; } -function stats( - executions: TourStatsExecutionRow[], - tracks: TourStatsTrackRow[] = [], - excluded: number[] = [], - userId = ME, -) { - return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) }); +function stats(executions: TourStatsExecutionRow[], tracks: TourStatsTrackRow[] = [], excluded: number[] = []) { + return buildActivityStats({ 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); + it('STA2-HELP-001: a day naming the user or nobody is theirs, one naming only others is not', () => { + expect(isOwnExecution(row({ joined: 1, participant_count: 2 }))).toBe(true); + expect(isOwnExecution(row({ participant_count: 0 }))).toBe(true); + expect(isOwnExecution(row({ participant_count: 1, joined: 0 }))).toBe(false); }); }); @@ -75,6 +65,7 @@ describe('buildActivityStats (STA-02)', () => { name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, @@ -89,14 +80,15 @@ describe('buildActivityStats (STA-02)', () => { }); }); - it('STA2-HELP-003: a tour on several days counts once, on its first day, saying on how many', () => { + it('STA2-HELP-003: a tour on several days counts once, on its last 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 }); + // Dated on the last day, so it is not done while it is still under way. + expect(entries[0]).toMatchObject({ date: '2026-07-04', day_count: 2 }); }); it('STA2-HELP-004: a linked recording replaces the plan and is no tour of its own', () => { @@ -117,10 +109,10 @@ describe('buildActivityStats (STA-02)', () => { 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', () => { + it('STA2-HELP-005: a recording on no day counts on the last 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']); + expect(noDay.entries.map((entry) => entry.date)).toEqual(['2026-07-03']); 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], @@ -152,14 +144,15 @@ describe('buildActivityStats (STA-02)', () => { 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-009: a day naming nobody counts for every member, on any trip the user can open', () => { + const shared = { trip_id: 6, trip_title: 'Theirs' }; + expect(stats([row(shared)]).entries).toEqual([expect.objectContaining({ trip: { id: 6, title: 'Theirs' } })]); + expect(stats([row({ ...shared, participant_count: 2, joined: 1 })]).entries).toHaveLength(1); + const onNoDay = row({ ...shared, place_id: 11, assignment_id: null, date: null }); + expect(stats([onNoDay]).uncounted).toEqual({ unassigned: 1, undated: 0 }); }); - it('STA2-HELP-010: a day naming only others leaves the owner out, and says nothing about it', () => { + it('STA2-HELP-010: a day naming only others leaves the user out, and says nothing about it', () => { expect(stats([row({ participant_count: 1, joined: 0 })])).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 }, @@ -175,7 +168,7 @@ describe('buildActivityStats (STA-02)', () => { 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', () => { + it('STA2-HELP-012: a whole route and the stages planned from it are all listed, each stage naming the route', () => { 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({ @@ -186,10 +179,22 @@ describe('buildActivityStats (STA-02)', () => { assignment_id: 302, date: '2026-07-03', }); - expect(stats([route, stage1, stage2]).entries.map((entry) => entry.place_id)).toEqual([32, 31]); + // Every entry is kept, each stage naming its route: whether a stage counts takes the reader's today. + const all = stats([route, stage1, stage2]).entries; + expect(all.map((entry) => [entry.place_id, entry.derived_from_place_id])).toEqual([ + [32, 30], + [31, 30], + [30, null], + ]); + // A route on no day whose stages are planned is not named as uncounted: its stages stand for it. + const routeOnNoDay = row({ ...route, assignment_id: null, date: null }); + expect(stats([routeOnNoDay, stage1, stage2]).uncounted).toEqual({ unassigned: 0, undated: 0 }); // 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]); + expect(stats([route, unplanned])).toMatchObject({ + entries: [expect.objectContaining({ place_id: 30 })], + uncounted: { unassigned: 1, undated: 0 }, + }); }); it('STA2-HELP-013: a paddle tour carries no heights, planned or recorded', () => { diff --git a/server/tests/unit/nest/tour-activity-stats.mcp.test.ts b/server/tests/unit/nest/tour-activity-stats.mcp.test.ts index 7d6d140e4..fac7d72a7 100644 --- a/server/tests/unit/nest/tour-activity-stats.mcp.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.mcp.test.ts @@ -13,7 +13,7 @@ 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 { 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'; @@ -68,7 +68,7 @@ async function fixture() { 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 }; + return { user, trip, tours, hidden }; } describe('TourActivityStatsMcp', () => { @@ -118,4 +118,13 @@ describe('TourActivityStatsMcp', () => { }); expect(await countRows(orm(), TourActivityExclusions)).toBe(0); }); + + it('STA2-MCP-005: a member counts the days naming nobody, which are everyone’s, as over REST', async () => { + const { trip, tours } = await fixture(); + const { user: member } = await makeUser(orm()); + await addTripMember(orm(), trip.id, member.id); + const result = read(await mcp.getTourActivityStats({}, ctx(member.id))); + expect(result.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([tours.Lake, tours.Ridge]); + expect(result.upcoming).toBe(1); + }); }); diff --git a/server/tests/unit/nest/tour-activity-stats.service.test.ts b/server/tests/unit/nest/tour-activity-stats.service.test.ts index 2ccf51eb4..8715d388c 100644 --- a/server/tests/unit/nest/tour-activity-stats.service.test.ts +++ b/server/tests/unit/nest/tour-activity-stats.service.test.ts @@ -46,8 +46,9 @@ const RECORDED = JSON.stringify([ /** * 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. + * a trip of a friend the user is a member of, with one tour whose day names + * only the friend, one naming only the user and one naming nobody, which is + * everyone's; and a trip the user cannot open. */ async function fixture() { const { user } = await makeUser(orm()); @@ -74,7 +75,11 @@ async function fixture() { 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 aloneOn = await makeDayAssignment(orm(), theirDay.id, alone.id); + await insertRow(orm(), AssignmentParticipants, { assignment: aloneOn.id, user: friend.id }); + const everyone = await makePlace(orm(), theirs.id, { name: 'Everyone' }); + await makeTour(orm(), everyone.id, { distance: 5 }); + await makeDayAssignment(orm(), theirDay.id, everyone.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); @@ -85,34 +90,35 @@ async function fixture() { 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 }; + return { user, friend, hike, paddle, watch, alone, everyone, 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 { user, hike, paddle, watch, everyone, together } = await fixture(); const result = await service.stats(user.id); expect(result.entries.map((entry) => [entry.place_id, entry.basis, entry.date])).toEqual([ + [everyone.id, 'plan', '2026-08-10'], [together.id, 'plan', '2026-08-10'], [paddle.id, 'track', '2026-07-03'], [hike.id, 'plan', '2026-07-02'], ]); - expect(result.entries[1]).toMatchObject({ + expect(result.entries[2]).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.entries[2]?.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(); + it('STA2-SVC-002: the friend sees their own and everyone’s tours, and not the user’s trip they cannot open', async () => { + const { friend, alone, everyone, 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.map((entry) => entry.place_id)).toEqual([alone.id, everyone.id]); expect(result.entries.some((entry) => entry.place_id === together.id)).toBe(false); }); diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 4df21a537..4408c87ef 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -1263,7 +1263,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'جولاتي المنجزة', 'tours.stats.sub': 'بناءً على تخطيط رحلتك؛ وتحل المسارات الفعلية المرتبطة محله', 'tours.stats.basisInfo': - 'تُحتسب الجولة بعد انتهاء يومها، بقيم تخطيطك. ويحل المسار الفعلي المرتبط محلها. لا تُحتسب إلا الأيام التي تشارك فيها.', + 'تُحتسب الجولة بعد انتهاء آخر أيامها، بقيم تخطيطك. ويحل المسار الفعلي المرتبط محلها. اليوم الذي يسمّي مشاركين يُحتسب لهم وحدهم.', 'tours.stats.basisPlan': 'مخطط', 'tours.stats.basisPlanHint': 'من تخطيط الرحلة، غير مقيس', 'tours.stats.basisTrack': 'مسجّل', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 9ffb522bf..e087e4e74 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -1231,7 +1231,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Tamamladığım turlar', 'tours.stats.sub': 'Səyahət planına əsasən; əlaqələndirilmiş faktiki izlər onu əvəz edir', 'tours.stats.basisInfo': - 'Tur günü bitəndə planlamanın dəyərləri ilə sayılır. Əlaqələndirilmiş faktiki iz onları əvəz edir. Yalnız iştirak etdiyin günlər sayılır.', + 'Tur son günü bitəndə planlamanın dəyərləri ilə sayılır. Əlaqələndirilmiş faktiki iz onları əvəz edir. İştirakçıları göstərilən gün yalnız onlar üçün sayılır.', 'tours.stats.basisPlan': 'Planlaşdırılıb', 'tours.stats.basisPlanHint': 'Səyahət planından, ölçülməyib', 'tours.stats.basisTrack': 'Qeydə alınıb', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index e99b0002e..3409577a3 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -1243,7 +1243,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Meus passeios realizados', 'tours.stats.sub': 'Com base no planejamento da viagem; trilhas reais vinculadas o substituem', 'tours.stats.basisInfo': - 'Um passeio conta quando o dia dele termina, com os valores do seu planejamento. Uma trilha real vinculada os substitui. Só contam os dias em que você participa.', + 'Um passeio conta quando o último dia dele termina, com os valores do seu planejamento. Uma trilha real vinculada os substitui. Um dia que indica participantes conta só para eles.', 'tours.stats.basisPlan': 'Planejado', 'tours.stats.basisPlanHint': 'Do planejamento da viagem, não medido', 'tours.stats.basisTrack': 'Gravado', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 249dd72bb..8b4c8590b 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -1250,7 +1250,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Les meves excursions fetes', 'tours.stats.sub': 'Segons la planificació del viatge; els traços reals vinculats la substitueixen', 'tours.stats.basisInfo': - 'Una excursió compta quan el seu dia s’ha acabat, amb els valors de la teva planificació. Un traç real vinculat els substitueix. Només compten els dies en què participes.', + 'Una excursió compta quan el seu últim dia s’ha acabat, amb els valors de la teva planificació. Un traç real vinculat els substitueix. Un dia que indica participants només compta per a ells.', 'tours.stats.basisPlan': 'Planificat', 'tours.stats.basisPlanHint': 'De la planificació del viatge, no mesurat', 'tours.stats.basisTrack': 'Enregistrat', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 65061bca7..bba24b74f 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -1229,7 +1229,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Moje absolvované výlety', 'tours.stats.sub': 'Podle plánu cesty; propojené skutečné trasy ho nahrazují', 'tours.stats.basisInfo': - 'Výlet se započítá, jakmile jeho den skončí, s hodnotami z tvého plánu. Propojená skutečná trasa je nahradí. Počítají se jen dny, kterých se účastníš.', + 'Výlet se započítá, jakmile skončí jeho poslední den, s hodnotami z tvého plánu. Propojená skutečná trasa je nahradí. Den, který uvádí účastníky, se počítá jen jim.', 'tours.stats.basisPlan': 'Plánováno', 'tours.stats.basisPlanHint': 'Z plánu cesty, neměřeno', 'tours.stats.basisTrack': 'Zaznamenáno', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 32259fa34..692dc63cd 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -1257,7 +1257,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Meine Touraktivitäten', 'tours.stats.sub': 'Auf Basis deiner Reiseplanung; verknüpfte Ist-Tracks ersetzen sie', 'tours.stats.basisInfo': - 'Eine Tour zählt, sobald ihr Tag vorbei ist, mit den Werten deiner Planung. Ein verknüpfter Ist-Track ersetzt sie. Es zählen nur Tage, an denen du teilnimmst.', + 'Eine Tour zählt, sobald ihr letzter Tag vorbei ist, mit den Werten deiner Planung. Ein verknüpfter Ist-Track ersetzt sie. Ein Tag, der Teilnehmende nennt, zählt nur für sie.', 'tours.stats.basisPlan': 'Geplant', 'tours.stats.basisPlanHint': 'Aus deiner Reiseplanung, nicht gemessen', 'tours.stats.basisTrack': 'Aufgezeichnet', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 7c6443786..d3ad782ae 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -1218,7 +1218,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'My tour activities', 'tours.stats.sub': 'Based on your trip planning; linked actual tracks replace it', 'tours.stats.basisInfo': - 'A tour counts once its day is over, with the values of your planning. A linked actual track replaces them. Only days you take part in count.', + 'A tour counts once its last day is over, with the values of your planning. A linked actual track replaces them. A day that names participants counts only for them.', 'tours.stats.basisPlan': 'Planned', 'tours.stats.basisPlanHint': 'From your trip planning, not measured', 'tours.stats.basisTrack': 'Recorded', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index ab64c5e6a..dc935c82e 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -1243,7 +1243,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Mis excursiones realizadas', 'tours.stats.sub': 'Según la planificación de tu viaje; los tracks reales vinculados la sustituyen', 'tours.stats.basisInfo': - 'Una excursión cuenta cuando su día ha terminado, con los valores de tu planificación. Un track real vinculado los sustituye. Solo cuentan los días en los que participas.', + 'Una excursión cuenta cuando su último día ha terminado, con los valores de tu planificación. Un track real vinculado los sustituye. Un día que indica participantes solo cuenta para ellos.', 'tours.stats.basisPlan': 'Planificado', 'tours.stats.basisPlanHint': 'Según la planificación del viaje, no medido', 'tours.stats.basisTrack': 'Grabado', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 8540050e8..2bee54d23 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -1227,7 +1227,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Minu tehtud retked', 'tours.stats.sub': 'Reisi planeerimise põhjal; lingitud tegelikud rajad asendavad selle', 'tours.stats.basisInfo': - 'Retk läheb arvesse, kui selle päev on läbi, sinu planeerimise väärtustega. Lingitud tegelik rada asendab need. Arvesse lähevad ainult päevad, millest võtad osa.', + 'Retk läheb arvesse, kui selle viimane päev on läbi, sinu planeerimise väärtustega. Lingitud tegelik rada asendab need. Päev, millel on osalejad nimetatud, läheb arvesse ainult neile.', 'tours.stats.basisPlan': 'Planeeritud', 'tours.stats.basisPlanHint': 'Reisi planeerimisest, mõõtmata', 'tours.stats.basisTrack': 'Salvestatud', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 0b64f4e4e..f73aad8d1 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -1267,7 +1267,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Mes sorties réalisées', 'tours.stats.sub': 'D’après la planification de ton voyage ; les traces réelles liées la remplacent', 'tours.stats.basisInfo': - 'Une sortie compte une fois sa journée terminée, avec les valeurs de ta planification. Une trace réelle liée les remplace. Seuls les jours auxquels tu participes comptent.', + 'Une sortie compte une fois son dernier jour terminé, avec les valeurs de ta planification. Une trace réelle liée les remplace. Un jour qui nomme des participants ne compte que pour eux.', 'tours.stats.basisPlan': 'Planifié', 'tours.stats.basisPlanHint': 'D’après la planification du voyage, non mesuré', 'tours.stats.basisTrack': 'Enregistré', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 9efcd67eb..bc54b6869 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -1270,7 +1270,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Οι εκδρομές μου', 'tours.stats.sub': 'Με βάση τον σχεδιασμό του ταξιδιού· οι συνδεδεμένες πραγματικές διαδρομές τον αντικαθιστούν', 'tours.stats.basisInfo': - 'Μια εκδρομή μετράει μόλις τελειώσει η μέρα της, με τις τιμές του σχεδιασμού σου. Μια συνδεδεμένη πραγματική διαδρομή τις αντικαθιστά. Μετράνε μόνο οι μέρες στις οποίες συμμετέχεις.', + 'Μια εκδρομή μετράει μόλις τελειώσει η τελευταία μέρα της, με τις τιμές του σχεδιασμού σου. Μια συνδεδεμένη πραγματική διαδρομή τις αντικαθιστά. Μια μέρα που ορίζει συμμετέχοντες μετράει μόνο για εκείνους.', 'tours.stats.basisPlan': 'Σχεδιασμένο', 'tours.stats.basisPlanHint': 'Από τον σχεδιασμό του ταξιδιού, χωρίς μέτρηση', 'tours.stats.basisTrack': 'Καταγεγραμμένο', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index da4a520a0..65d13ce22 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -1248,7 +1248,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Teljesített túráim', 'tours.stats.sub': 'Az utazás tervezése alapján; az összekapcsolt tényleges nyomvonalak felváltják', 'tours.stats.basisInfo': - 'Egy túra akkor számít, ha a napja véget ért, a tervezésed értékeivel. Egy összekapcsolt tényleges nyomvonal felváltja ezeket. Csak azok a napok számítanak, amelyeken részt veszel.', + 'Egy túra akkor számít, ha az utolsó napja véget ért, a tervezésed értékeivel. Egy összekapcsolt tényleges nyomvonal felváltja ezeket. Egy nap, amely résztvevőket nevez meg, csak nekik számít.', 'tours.stats.basisPlan': 'Tervezett', 'tours.stats.basisPlanHint': 'Az utazás tervezéséből, nem mért', 'tours.stats.basisTrack': 'Rögzített', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index f4e1f6731..0e64f83fc 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -1229,7 +1229,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Tur yang sudah kulakukan', 'tours.stats.sub': 'Berdasarkan perencanaan perjalananmu; jejak aktual yang ditautkan menggantikannya', 'tours.stats.basisInfo': - 'Tur dihitung setelah harinya berakhir, dengan nilai dari perencanaanmu. Jejak aktual yang ditautkan menggantikannya. Hanya hari yang kamu ikuti yang dihitung.', + 'Tur dihitung setelah hari terakhirnya berakhir, dengan nilai dari perencanaanmu. Jejak aktual yang ditautkan menggantikannya. Hari yang menyebut peserta hanya dihitung untuk mereka.', 'tours.stats.basisPlan': 'Direncanakan', 'tours.stats.basisPlanHint': 'Dari perencanaan perjalanan, tidak diukur', 'tours.stats.basisTrack': 'Terekam', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 4f3b7ae83..e68cec891 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -1257,7 +1257,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Le mie escursioni svolte', 'tours.stats.sub': 'In base alla pianificazione del viaggio; le tracce effettive collegate la sostituiscono', 'tours.stats.basisInfo': - 'Un’escursione conta quando il suo giorno è finito, con i valori della tua pianificazione. Una traccia effettiva collegata li sostituisce. Contano solo i giorni a cui partecipi.', + 'Un’escursione conta quando il suo ultimo giorno è finito, con i valori della tua pianificazione. Una traccia effettiva collegata li sostituisce. Un giorno che indica dei partecipanti conta solo per loro.', 'tours.stats.basisPlan': 'Pianificato', 'tours.stats.basisPlanHint': 'Dalla pianificazione del viaggio, non misurato', 'tours.stats.basisTrack': 'Registrato', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index f550cc24a..a42986ed9 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -1193,7 +1193,7 @@ const tours: TranslationStrings = { 'tours.stats.title': '自分のツアー実績', 'tours.stats.sub': '旅行の計画に基づく値。リンクした実際のトラックがあればそちらに置き換わります', 'tours.stats.basisInfo': - 'ツアーはその日が終わると、計画の値で集計されます。リンクした実際のトラックがあればその値に置き換わります。参加している日だけが集計されます。', + 'ツアーは最終日が終わると、計画の値で集計されます。リンクした実際のトラックがあればその値に置き換わります。参加者が指定された日は、その参加者だけに集計されます。', 'tours.stats.basisPlan': '計画', 'tours.stats.basisPlanHint': '旅行の計画による値で、計測値ではありません', 'tours.stats.basisTrack': '記録', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 5e42cd21a..28228d284 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -1191,7 +1191,7 @@ const tours: TranslationStrings = { 'tours.stats.title': '나의 투어 기록', 'tours.stats.sub': '여행 계획 기준이며, 연결된 실제 트랙이 있으면 그것으로 대체됩니다', 'tours.stats.basisInfo': - '투어는 해당 날짜가 지나면 계획 값으로 집계됩니다. 연결된 실제 트랙이 있으면 그 값으로 대체됩니다. 참여하는 날만 집계됩니다.', + '투어는 마지막 날이 지나면 계획 값으로 집계됩니다. 연결된 실제 트랙이 있으면 그 값으로 대체됩니다. 참가자가 지정된 날은 그 참가자에게만 집계됩니다.', 'tours.stats.basisPlan': '계획', 'tours.stats.basisPlanHint': '여행 계획의 값이며 측정값이 아닙니다', 'tours.stats.basisTrack': '기록', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 737e0c97b..427fa7fd3 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -1250,7 +1250,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Mijn gemaakte tochten', 'tours.stats.sub': 'Op basis van je reisplanning; gekoppelde werkelijke tracks vervangen die', 'tours.stats.basisInfo': - 'Een tocht telt zodra zijn dag voorbij is, met de waarden van je planning. Een gekoppelde werkelijke track vervangt ze. Alleen dagen waaraan je meedoet tellen.', + 'Een tocht telt zodra zijn laatste dag voorbij is, met de waarden van je planning. Een gekoppelde werkelijke track vervangt ze. Een dag die deelnemers noemt, telt alleen voor hen.', 'tours.stats.basisPlan': 'Gepland', 'tours.stats.basisPlanHint': 'Uit je reisplanning, niet gemeten', 'tours.stats.basisTrack': 'Opgenomen', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 279c89bea..4915dd6fb 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -1271,7 +1271,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Moje odbyte wycieczki', 'tours.stats.sub': 'Na podstawie planu podróży; połączone rzeczywiste trasy go zastępują', 'tours.stats.basisInfo': - 'Wycieczka liczy się, gdy jej dzień się skończy, z wartościami z twojego planu. Połączona rzeczywista trasa je zastępuje. Liczą się tylko dni, w których bierzesz udział.', + 'Wycieczka liczy się, gdy skończy się jej ostatni dzień, z wartościami z twojego planu. Połączona rzeczywista trasa je zastępuje. Dzień ze wskazanymi uczestnikami liczy się tylko dla nich.', 'tours.stats.basisPlan': 'Zaplanowane', 'tours.stats.basisPlanHint': 'Z planu podróży, nie zmierzone', 'tours.stats.basisTrack': 'Nagrane', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 636f58a42..126ce0a33 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -1278,7 +1278,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Мои пройденные туры', 'tours.stats.sub': 'По планированию поездки; привязанные фактические треки заменяют его', 'tours.stats.basisInfo': - 'Тур засчитывается, когда его день закончился, со значениями из твоего планирования. Привязанный фактический трек их заменяет. Учитываются только дни, в которых ты участвуешь.', + 'Тур засчитывается, когда закончился его последний день, со значениями из твоего планирования. Привязанный фактический трек их заменяет. День, в котором указаны участники, засчитывается только им.', 'tours.stats.basisPlan': 'Запланировано', 'tours.stats.basisPlanHint': 'Из планирования поездки, не измерено', 'tours.stats.basisTrack': 'Записано', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 8da0db9ae..33bff885e 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -1233,7 +1233,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Moje absolvované túry', 'tours.stats.sub': 'Podľa plánu cesty; prepojené skutočné trasy ho nahrádzajú', 'tours.stats.basisInfo': - 'Túra sa započíta, keď jej deň skončí, s hodnotami z tvojho plánu. Prepojená skutočná trasa ich nahradí. Počítajú sa len dni, na ktorých sa zúčastňuješ.', + 'Túra sa započíta, keď skončí jej posledný deň, s hodnotami z tvojho plánu. Prepojená skutočná trasa ich nahradí. Deň, ktorý uvádza účastníkov, sa počíta len im.', 'tours.stats.basisPlan': 'Plánované', 'tours.stats.basisPlanHint': 'Z plánu cesty, nemerané', 'tours.stats.basisTrack': 'Zaznamenané', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 122560989..bf4685b06 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -1222,7 +1222,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Mina genomförda turer', 'tours.stats.sub': 'Utifrån din reseplanering; länkade faktiska spår ersätter den', 'tours.stats.basisInfo': - 'En tur räknas när dess dag är över, med värdena från din planering. Ett länkat faktiskt spår ersätter dem. Bara dagar du deltar i räknas.', + 'En tur räknas när dess sista dag är över, med värdena från din planering. Ett länkat faktiskt spår ersätter dem. En dag som anger deltagare räknas bara för dem.', 'tours.stats.basisPlan': 'Planerat', 'tours.stats.basisPlanHint': 'Från reseplaneringen, inte uppmätt', 'tours.stats.basisTrack': 'Inspelat', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 933b9eb72..1b6d1826c 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -1174,7 +1174,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'ทัวร์ที่ฉันทำแล้ว', 'tours.stats.sub': 'อิงจากการวางแผนทริป เส้นทางจริงที่เชื่อมไว้จะใช้แทน', 'tours.stats.basisInfo': - 'ทัวร์จะนับเมื่อวันของทัวร์ผ่านไปแล้ว โดยใช้ค่าจากการวางแผนของคุณ เส้นทางจริงที่เชื่อมไว้จะใช้แทนค่าเหล่านั้น นับเฉพาะวันที่คุณเข้าร่วม', + 'ทัวร์จะนับเมื่อวันสุดท้ายของทัวร์ผ่านไปแล้ว โดยใช้ค่าจากการวางแผนของคุณ เส้นทางจริงที่เชื่อมไว้จะใช้แทนค่าเหล่านั้น วันที่ระบุผู้เข้าร่วมจะนับให้เฉพาะคนเหล่านั้น', 'tours.stats.basisPlan': 'ตามแผน', 'tours.stats.basisPlanHint': 'จากการวางแผนทริป ไม่ได้วัดจริง', 'tours.stats.basisTrack': 'บันทึกจริง', diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index ab25dc3af..0543db083 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -1229,7 +1229,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Tamamladığım turlar', 'tours.stats.sub': 'Seyahat planına göre; bağlanan gerçek izler onun yerini alır', 'tours.stats.basisInfo': - 'Bir tur, günü bittiğinde planlamandaki değerlerle sayılır. Bağlanan gerçek bir iz bunların yerini alır. Yalnızca katıldığın günler sayılır.', + 'Bir tur, son günü bittiğinde planlamandaki değerlerle sayılır. Bağlanan gerçek bir iz bunların yerini alır. Katılımcıları belirtilen bir gün yalnızca onlar için sayılır.', 'tours.stats.basisPlan': 'Planlanan', 'tours.stats.basisPlanHint': 'Seyahat planından, ölçülmedi', 'tours.stats.basisTrack': 'Kaydedilen', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 0c1cd1c51..69a19173e 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -1264,7 +1264,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Мої пройдені тури', 'tours.stats.sub': 'За плануванням подорожі; прив’язані фактичні треки замінюють його', 'tours.stats.basisInfo': - 'Тур зараховується, коли його день завершився, зі значеннями з твого планування. Прив’язаний фактичний трек їх замінює. Враховуються лише дні, у яких ти береш участь.', + 'Тур зараховується, коли завершився його останній день, зі значеннями з твого планування. Прив’язаний фактичний трек їх замінює. День, у якому вказано учасників, зараховується лише їм.', 'tours.stats.basisPlan': 'Заплановано', 'tours.stats.basisPlanHint': 'З планування подорожі, не виміряно', 'tours.stats.basisTrack': 'Записано', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 5f304d828..10592cfc7 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -1218,7 +1218,7 @@ const tours: TranslationStrings = { 'tours.stats.title': 'Các chuyến tôi đã đi', 'tours.stats.sub': 'Dựa trên kế hoạch chuyến đi; lộ trình thực tế được liên kết sẽ thay thế', 'tours.stats.basisInfo': - 'Một chuyến được tính khi ngày của nó đã qua, với các giá trị từ kế hoạch của bạn. Lộ trình thực tế được liên kết sẽ thay thế chúng. Chỉ tính những ngày bạn tham gia.', + 'Một chuyến được tính khi ngày cuối của nó đã qua, với các giá trị từ kế hoạch của bạn. Lộ trình thực tế được liên kết sẽ thay thế chúng. Ngày có ghi người tham gia chỉ được tính cho những người đó.', 'tours.stats.basisPlan': 'Theo kế hoạch', 'tours.stats.basisPlanHint': 'Từ kế hoạch chuyến đi, không đo thực tế', 'tours.stats.basisTrack': 'Đã ghi', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index d4b86096c..d1860ee3c 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -1133,7 +1133,8 @@ const tours: TranslationStrings = { 'tours.activity.compare.hide': '結束比較', 'tours.stats.title': '我的遊程紀錄', 'tours.stats.sub': '以你的行程規劃為準;已連結的實際軌跡會取代規劃值', - 'tours.stats.basisInfo': '遊程在其日期結束後計入,使用你的規劃值。已連結的實際軌跡會取代這些值。只計入你參加的日子。', + 'tours.stats.basisInfo': + '遊程在其最後一天結束後計入,使用你的規劃值。已連結的實際軌跡會取代這些值。指定了參與者的日子只為這些參與者計入。', 'tours.stats.basisPlan': '規劃', 'tours.stats.basisPlanHint': '來自行程規劃,並非實測', 'tours.stats.basisTrack': '已記錄', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 966129f09..0683e6116 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -1134,7 +1134,8 @@ const tours: TranslationStrings = { 'tours.activity.compare.hide': '结束比较', 'tours.stats.title': '我的游览记录', 'tours.stats.sub': '基于你的行程规划;已关联的实际轨迹会替代规划值', - 'tours.stats.basisInfo': '游览在其日期结束后计入,使用你的规划值。已关联的实际轨迹会替代这些值。只计入你参加的日子。', + 'tours.stats.basisInfo': + '游览在其最后一天结束后计入,使用你的规划值。已关联的实际轨迹会替代这些值。指定了参与者的日子只为这些参与者计入。', 'tours.stats.basisPlan': '规划', 'tours.stats.basisPlanHint': '来自行程规划,并非实测', 'tours.stats.basisTrack': '已记录', diff --git a/shared/src/tours/tour-activity-stats.schema.ts b/shared/src/tours/tour-activity-stats.schema.ts index 7378e027b..71587c462 100644 --- a/shared/src/tours/tour-activity-stats.schema.ts +++ b/shared/src/tours/tour-activity-stats.schema.ts @@ -9,17 +9,20 @@ import { z } from 'zod'; * * - The plan's values stand in for the tour while nothing was recorded * (`basis: 'plan'`), and say so. A tour on several days still counts once - * (`day_count` says on how many), on the first of them. + * (`day_count` says on how many), on the last of them, so a tour still under + * way is not done yet. * - A recorded track linked to an execution of the tour (ACT-01) takes the * plan's place (`basis: 'track'`), one entry per recording; the plan of that * tour is then not counted besides it, and the recording is never counted as * a tour of its own. * - An import some other tour of the trip was planned from (the whole route a - * multi-day tour's stages were cut from) is left out while one of those - * tours counts, so the route and its stages are never added up together. + * multi-day tour's stages were cut from) is named by those tours in + * `derived_from_place_id`, and the summary leaves it out while one of them + * counts, so the route and its stages are never added up together. A stage + * still ahead or left out does not hide the route. * - Only the user's own part counts: an execution names the user as one of its - * participants, or names none and the trip is the user's own. Being able to - * open someone else's trip makes none of its tours the user's. + * participants, or names nobody, which in the planner means everyone of the + * trip. A day that names participants without the user is not theirs. * * Whether an entry is done is the reader's question (`date` before today, in * the reader's own time zone): the server answers every entry it found, the @@ -39,7 +42,9 @@ export const tourActivityStatsEntrySchema = z.object({ name: z.string(), tour_type: tourTypeKeySchema, trip: z.object({ id: idSchema, title: z.string() }), - /** The day it was done on: the recording's day, else the first of the user's dated days the tour is on. */ + /** The whole route this tour was planned from, a stage of it; null for any other tour. */ + derived_from_place_id: idSchema.nullable(), + /** The day it was done on: the recording's day, else the last of the user's dated days the tour is on. */ date: tourActivityDateSchema, basis: tourActivityBasisSchema, /** The recording whose values these are; null for the plan. */ @@ -59,8 +64,9 @@ export const tourActivityStatsResponseSchema = z.object({ /** The newest first. */ entries: z.array(tourActivityStatsEntrySchema), /** - * Tours of the user's own trips that cannot count as done: on no day at all, - * or only on days without a date. Said, so a missing tour is not a mystery. + * Tours of the user's trips that cannot count as done: on no day at all, or + * only on the user's days without a date. A whole route whose stages are on + * the user's days is not among them. Said, so a missing tour is not a mystery. */ uncounted: z.object({ unassigned: z.number().int().nonnegative(), diff --git a/shared/src/tours/tour-activity-stats.spec.ts b/shared/src/tours/tour-activity-stats.spec.ts index e7d0fdefa..b5a3437b3 100644 --- a/shared/src/tours/tour-activity-stats.spec.ts +++ b/shared/src/tours/tour-activity-stats.spec.ts @@ -14,6 +14,7 @@ function entry(overrides: Partial = {}): TourActivitySta name: 'Ridge', tour_type: 'hike', trip: { id: 5, title: 'Alps' }, + derived_from_place_id: null, date: '2026-07-02', basis: 'plan', track: null, @@ -183,4 +184,44 @@ describe('STA-02 summarizeTourActivities', () => { ); expect(summary.done.map((item) => item.name)).toEqual(['Alpspitze', 'Zugspitze']); }); + + it('STA2-SUM-009: a whole route stays out while a stage planned from it is done and counted', () => { + const route = entry({ place_id: 30, name: 'Whole route', distance_km: 60, date: '2025-08-01' }); + const stage = entry({ place_id: 31, distance_km: 20, derived_from_place_id: 30, date: '2025-08-02' }); + const summary = summarizeTourActivities([route, stage], { today: TODAY }); + expect(summary.done.map((item) => item.place_id)).toEqual([31]); + expect(summary.totals.distanceKm.value).toBe(20); + }); + + it('STA2-SUM-010: a stage still ahead or left out hides nothing, so the route walked before still counts', () => { + const route = entry({ place_id: 30, name: 'Whole route', distance_km: 60, date: '2025-08-01' }); + const ahead = entry({ place_id: 31, derived_from_place_id: 30, date: '2026-11-02' }); + const upcoming = summarizeTourActivities([route, ahead], { today: TODAY }); + expect(upcoming.done.map((item) => item.place_id)).toEqual([30]); + expect(upcoming.totals.distanceKm.value).toBe(60); + expect(upcoming.upcoming).toBe(1); + const leftOut = entry({ place_id: 32, derived_from_place_id: 30, date: '2025-08-02', excluded: true }); + const fellThrough = summarizeTourActivities([route, leftOut], { today: TODAY }); + expect(fellThrough.done.map((item) => [item.place_id, item.excluded])).toEqual([ + [32, true], + [30, false], + ]); + expect(fellThrough.totals.count).toBe(1); + }); + + it('STA2-SUM-011: a stage outside the period does not hide the route either', () => { + const route = entry({ place_id: 30, distance_km: 60, date: '2025-12-30' }); + const stage = entry({ place_id: 31, derived_from_place_id: 30, date: '2026-01-02' }); + const lastYear = summarizeTourActivities([route, stage], { today: TODAY, to: '2025-12-31' }); + expect(lastYear.done.map((item) => item.place_id)).toEqual([30]); + }); + + it('STA2-SUM-012: a tour on two days is dated on the last one, so it is not done between them', () => { + const twoDays = entry({ date: TODAY, day_count: 2 }); + expect(isTourActivityDone(twoDays, TODAY)).toBe(false); + const summary = summarizeTourActivities([twoDays], { today: TODAY }); + expect(summary.totals.count).toBe(0); + expect(summary.upcoming).toBe(1); + expect(summarizeTourActivities([twoDays], { today: '2026-10-21' }).totals.count).toBe(1); + }); }); diff --git a/shared/src/tours/tour-activity-stats.ts b/shared/src/tours/tour-activity-stats.ts index 16ed2d39a..19398a868 100644 --- a/shared/src/tours/tour-activity-stats.ts +++ b/shared/src/tours/tour-activity-stats.ts @@ -76,14 +76,29 @@ function totalsOf(entries: readonly TourActivityStatsEntry[]): TourActivityTotal }; } +/** + * A whole route counts only while none of the stages planned from it does: + * once a stage is done, counted and in the reader's period and type, the + * stages stand for the route. A stage still ahead or left out hides nothing. + */ +function withoutRoutesOfCountedStages( + entries: readonly TourActivityStatsEntry[], + counted: readonly TourActivityStatsEntry[], +): TourActivityStatsEntry[] { + const routes = new Set(counted.map((entry) => entry.derived_from_place_id)); + return entries.filter((entry) => !routes.has(entry.place_id)); +} + /** The entries of the period and type, done and counted, summed as a whole and by type. */ export function summarizeTourActivities( entries: readonly TourActivityStatsEntry[], filter: TourActivityFilter, ): TourActivitySummary { - const matching = entries.filter( + const inScope = entries.filter( (entry) => inPeriod(entry.date, filter) && (!filter.type || entry.tour_type === filter.type), ); + const countedInScope = inScope.filter((entry) => !entry.excluded && isTourActivityDone(entry, filter.today)); + const matching = withoutRoutesOfCountedStages(inScope, countedInScope); const done = matching.filter((entry) => isTourActivityDone(entry, filter.today)); const counted = done.filter((entry) => !entry.excluded); const byType = new Map(); diff --git a/wiki/Atlas.md b/wiki/Atlas.md index 4a3e2a1ea..6378ff772 100644 --- a/wiki/Atlas.md +++ b/wiki/Atlas.md @@ -77,10 +77,10 @@ Your Atlas statistics panel shows: With the Tours addon on, the desktop panel has a third tab, **Tours**, next to **Stats** and **Bucket List**: what you have done on your tours across all your trips. Pick a **period** (all time, this year, the last 12 months or an earlier year) and a **tour type**, and the tab shows the distance 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 as a list. -- **When a tour counts.** A tour counts once the day it is on in a trip is over. A tour on no day, a tour only on days without a date and a tour whose day is still ahead do not count; the tab says how many of each there are. +- **When a tour counts.** A tour counts once the day it is on in a trip is over; a tour on several days once the last of them is over. A tour on no day, a tour only on days without a date and a tour whose day is still ahead do not count; the tab says how many of each there are. - **Planned or recorded.** Until you link the track you actually took in the tour's detail, a tour counts with the values of your planning, and it says so with a **Planned** badge: they are not measured. A linked actual track replaces those values for that tour, marked **Recorded**; it is never added on top, and the track itself is not counted as a tour of its own. -- **Only your own part.** A day of a tour counts for you when it names you among its participants, or names nobody on a trip of your own. Being able to open somebody else's trip does not make its tours yours. -- **No double counting.** A tour planned on several days counts once. A whole route that the stages of a multi-day tour were planned from does not count beside them. A paddle tour adds no ascent or descent, and a value a tour does not know stays out of the sum, which then reads "at least". +- **Only your own part.** A day of a tour counts for you when it names you among its participants, or names nobody, which in the planner means everyone of the trip. A day that names only others counts for them, not for you. +- **No double counting.** A tour planned on several days counts once. A whole route that the stages of a multi-day tour were planned from does not count beside them while one of those stages counts; a stage still ahead or left out does not hide it. A paddle tour adds no ascent or descent, and a value a tour does not know stays out of the sum, which then reads "at least". - **Leave a tour out.** A tour that fell through can be left out in the list with **Leave out**. It stays in the plan, its route and days do not change, it stays in the list dimmed, and **Count again** brings it back. Leaving it out changes only your own numbers. The tab reads live and needs a connection. diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index cd2d29ae8..3e06091de 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -246,7 +246,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how | `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` | | `assign_tour_track` | Links an actual track to a saved tour, once the user chose it: another tour of the same trip that came in through the GPX import or from wanderer and was not planned over, for one of the tour's day assignments or for none. A day that already has one gets this one instead; a track belongs to one tour only. The planned line, measures and times of the tour do not change. Needs `place_edit` | `trips:write` | | `unassign_tour_track` | Unlinks an actual track from a tour again; the track stays in the trip as the import it is and the tour does not change. Needs `place_edit` | `trips:write` | -| `get_tour_activity_stats` | The tours the user has done across the trips they can open, summed: how many, distance, ascent and descent, as a whole and by type, for a period (`from`, `to`) and one type. A tour counts once the day it is on is over, and only where the user took part (named among the participants of that day, or nobody named on a trip of their own); being able to open a trip of somebody else counts nothing. Each tour says what its values rest on: `plan` (the planning of the trip, not a measurement) or `track` (a linked actual track, which replaces the plan instead of adding to it). A tour on several days counts once, an import other tours were planned from does not count beside them, a paddle tour sums no heights, and an unknown value stays unknown. Tours the user left out are listed but not summed; `uncounted` names those of their own trips on no day or on days without a date | `trips:read` | +| `get_tour_activity_stats` | The tours the user has done across the trips they can open, summed: how many, distance, ascent and descent, as a whole and by type, for a period (`from`, `to`) and one type. A tour counts once the last day it is on is over, and only where the user takes part: named among the participants of that day, or nobody named, which means everyone of the trip; a day naming only others is theirs. Each tour says what its values rest on: `plan` (the planning of the trip, not a measurement) or `track` (a linked actual track, which replaces the plan instead of adding to it). A tour on several days counts once, an import other tours were planned from does not count beside them while one of them counts (`derived_from_place_id` names it), a paddle tour sums no heights, and an unknown value stays unknown. Tours the user left out are listed but not summed; `uncounted` names those of their trips on no day or on days without a date | `trips:read` | | `set_tour_activity_excluded` | Leaves a tour out of the user's own activity sums, or counts it again, for instance a tour that fell through. The tour, its days and its plan do not change, and nobody else's sums do | `trips:write` | | `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours, and none of the visits of the other trip's days. Needs `place_edit` on the trip it writes into | `trips:write` | | `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |