mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
fix(atlas): count shared days for every member, keep routes and multi-day tours honest (STA-02)
A day of a tour that names nobody now counts for every member of the trip, not only for its owner. In the planner an empty participant list means everyone, and a full member list is stored back as empty, so members got nothing for most shared tours. Only a day that names participants without the user stays theirs. The tours of a shared trip on no day or only on undated days are now named as not counting for members as well. A whole route a multi-day tour's stages were planned from is no longer dropped on the server as soon as any stage has a day. Each entry carries derived_from_place_id and the shared summary leaves the route out only while one of its stages is done, counted and in the period and type. A route walked last year now still counts when a stage of it is planned for next month or was left out because it fell through, in the Atlas and over MCP alike. A tour on several days is dated on the last of them, as is a recording on no day, so it no longer counts while it is still under way. The info text, the get_tour_activity_stats description and the wiki say so.
This commit is contained in:
@@ -28,6 +28,7 @@ function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivitySta
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
|
||||
@@ -26,6 +26,7 @@ function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivitySta
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivitySta
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivitySta
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TourActivities> {
|
||||
'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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<number>) {
|
||||
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<number>,
|
||||
): 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<number>,
|
||||
): 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 };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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> = {}): TourStatsExecutionRow {
|
||||
return {
|
||||
place_id: 10,
|
||||
@@ -21,7 +18,6 @@ function row(overrides: Partial<TourStatsExecutionRow> = {}): 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> = {}): 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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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': 'مسجّل',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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': 'Καταγεγραμμένο',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '記録',
|
||||
|
||||
@@ -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': '기록',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Записано',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'บันทึกจริง',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Записано',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '已記錄',
|
||||
|
||||
@@ -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': '已记录',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -14,6 +14,7 @@ function entry(overrides: Partial<TourActivityStatsEntry> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TourTypeKey, TourActivityStatsEntry[]>();
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user