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

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

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

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
This commit is contained in:
Maurice
2026-10-20 13:43:12 +02:00
parent ae79ab8dad
commit bde2ad2ca8
76 changed files with 4717 additions and 16 deletions
+10
View File
@@ -0,0 +1,10 @@
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
import apiClient from './client';
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
export const tourActivityStatsApi = {
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
};
@@ -0,0 +1,149 @@
// FE-STA2-PANE-001 to FE-STA2-PANE-006
// STA-02: the Atlas tab of the user's own tour activities: the distance large
// with how many tours at its corner, the climb, the types, what the numbers
// rest on, and the full list in a dialog.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { buildSettings } from '../../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
import type { TourActivityStats } from './useTourActivityStats';
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
vi.mock('./useTourActivityStats', () => ({
useTourActivityStats: (active: boolean) => {
hook.active.push(active);
return hook.current;
},
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
const stats: TourActivityStats = {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
hook.current = stats;
return stats;
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
entry({
place_id: 3,
name: 'Lake',
tour_type: 'kayak',
basis: 'track',
track: { place_id: 9, name: 'Watch' },
distance_km: 8,
ascent_m: null,
descent_m: null,
}),
],
uncounted: { unassigned: 0, undated: 1 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
hook.active = [];
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('AtlasTourActivityPane', () => {
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
statsOf(null, { status: 'idle' });
const { rerender } = render(<AtlasTourActivityPane active={false} />);
expect(hook.active).toEqual([false]);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
statsOf(null);
rerender(<AtlasTourActivityPane active />);
expect(hook.active).toEqual([false, true]);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
});
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
});
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
});
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
});
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
statsOf(data);
const user = userEvent.setup();
render(<AtlasTourActivityPane active />);
await user.click(screen.getByRole('button', { name: 'Show tours' }));
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
});
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
statsOf(null, { status: 'offline' });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
const failed = statsOf(null, { status: 'error' });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
expect(failed.retry).toHaveBeenCalled();
});
});
@@ -0,0 +1,234 @@
import type { TourActivitySummary } from '@trek/shared';
import { Footprints, ListOrdered } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../../Tours/tourParts';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import { splitMeasure } from './tourActivityStatsModel';
import {
ActivityFilters,
ActivityStatusLine,
BasisBadges,
distanceReading,
heightReading,
typeIcon,
typeLabel,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
/** How many types the strip names before the dialog takes over. */
const STRIP_TYPES = 3;
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
function Divider() {
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
}
/**
* The distance of the period, large, with how many tours at its corner; the
* tour count stands in when no tour knows its distance.
*/
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
const reading = distanceReading(summary.totals, unit);
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
return (
<div
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
style={{ background: HERO_TINT }}
>
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
{shown.value}
</span>
{shown.unit && (
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
{shown.unit}
</span>
)}
</div>
<span className={EYEBROW} style={fs(9)}>
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
</span>
{reading && (
<Tooltip label={countLabel}>
<span
aria-label={countLabel}
data-testid="atlas-tour-activity-count"
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(11)}
>
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
{summary.totals.count}
</span>
</Tooltip>
)}
</div>
);
}
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
const { value, unit } = splitMeasure(text);
return (
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
<span className="flex items-baseline gap-1">
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
{value}
</span>
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
{unit}
</span>
</span>
<span className={EYEBROW} style={fs(9)}>
{label}
</span>
</div>
);
}
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
return (
<div className="flex flex-none items-center gap-4 px-2 py-3">
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
const Icon = typeIcon(scope.type);
const distance = distanceReading(scope, unit);
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
return (
<Tooltip key={scope.type} label={label}>
<span
aria-label={label}
className="flex flex-col items-center gap-1.5"
data-testid={`atlas-tour-activity-type-${scope.type}`}
>
<span className="relative">
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
<Icon size={15} strokeWidth={1.9} />
</span>
<span
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9)}
>
{scope.count}
</span>
</span>
{distance && (
<span
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
style={fs(11, 'body')}
>
{distance.text}
</span>
)}
</span>
</Tooltip>
);
})}
</div>
);
}
/** What the sums rest on, what does not count, and the way into the full list. */
interface StripProps {
stats: TourActivityStats;
summary: TourActivitySummary;
onOpen: () => void;
}
function SideColumn({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
return (
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
<div className="flex flex-wrap items-center gap-1.5">
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
</div>
{summary.done.length > 0 && (
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
{t('tours.stats.showTours')}
</CardAction>
)}
</div>
);
}
/** The numbers of the period, or why there are none, with the filters before them. */
function Strip({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const ascent = heightReading(summary.totals.ascentM, unit);
const descent = heightReading(summary.totals.descentM, unit);
const counted = summary.totals.count > 0;
return (
<>
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<ActivityFilters stats={stats} quiet />
</div>
{counted ? (
<>
<Hero summary={summary} unit={unit} />
{ascent && (
<Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />
)}
{descent && (
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
)}
{stats.type === 'all' && summary.types.length > 1 && (
<>
<Divider />
<TypeColumns summary={summary} unit={unit} />
</>
)}
</>
) : (
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
</p>
)}
<Divider />
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
</>
);
}
/**
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
* the distance done with how many tours at its corner, the climb, the types,
* and what the numbers rest on (the trip planning, or recorded tracks where
* linked). The full list, where a tour can be left out, opens in a dialog. The
* read waits until the tab is first shown.
*/
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
const stats = useTourActivityStats(active);
const [open, setOpen] = useState(false);
return (
<div
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
style={{ maxWidth }}
data-testid="atlas-tour-activity"
>
{stats.summary ? (
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
) : (
<div className="flex items-center px-6 py-5">
<ActivityStatusLine stats={stats} />
</div>
)}
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
</div>
);
}
@@ -0,0 +1,158 @@
import type { TourActivityStatsEntry } from '@trek/shared';
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { EntryTile, MeasurePills, useDistanceUnit } from './tourActivityParts';
const ROW_BADGE_ICON = 'flex-none text-content-faint';
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
const { t } = useTranslation();
const track = entry.basis === 'track';
const hint = track
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
: t('tours.stats.basisPlanHint');
return (
<Tooltip label={hint}>
<span
className="inline-flex"
aria-label={hint}
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
>
<SoftPill
tone={track ? 'success' : 'neutral'}
icon={
track ? (
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
) : (
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
)
}
>
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
</SoftPill>
</span>
</Tooltip>
);
}
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
const { t, locale } = useTranslation();
const navigate = useNavigate();
return (
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{formatDate(entry.date, locale) ?? entry.date}
</WhiteBadge>
<Tooltip label={t('tours.stats.openTrip')}>
<button
type="button"
onClick={() => navigate(`/trips/${entry.trip.id}`)}
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<WhiteBadge
className="min-w-0 max-w-full"
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
>
<span className="truncate">{entry.trip.title}</span>
</WhiteBadge>
</button>
</Tooltip>
{entry.day_count > 1 && (
<Tooltip label={t('tours.stats.multiDayHint')}>
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{entry.day_count}
</WhiteBadge>
</span>
</Tooltip>
)}
<BasisPill entry={entry} />
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
</span>
);
}
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
function ExcludeToggle({
entry,
busy,
onToggle,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
let icon = <Ban size={14} strokeWidth={2} />;
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
return (
<Tooltip label={hint}>
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={label}
aria-pressed={entry.excluded}
data-testid={`tour-activity-toggle-${entry.place_id}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
>
{icon}
</button>
</Tooltip>
);
}
/**
* STA-02: one tour of the user's activities: its type, name and where it
* counts, its distance and climb, and the switch to leave it out. A tour left
* out stays in the list, dimmed, so it can be counted again in place.
*/
export function TourActivityEntryRow({
entry,
busy,
onToggleExcluded,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
}) {
const unit = useDistanceUnit();
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
return (
<li
data-testid={`tour-activity-row-${key}`}
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
>
<span
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
>
<EntryTile entry={entry} />
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{entry.name}
</span>
<EntryFacts entry={entry} />
</span>
<span className="flex flex-none flex-col items-end gap-1">
<MeasurePills
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
testId={`tour-activity-${key}`}
/>
</span>
</span>
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
</li>
);
}
@@ -0,0 +1,176 @@
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
// STA-02: the full list of the user's tour activities: the sums with what they
// rest on, the split by type, each tour with its basis and a switch to leave it
// out (dimmed, never hidden), and the way into its trip.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { buildSettings } from '../../../../tests/helpers/factories';
import { useSettingsStore } from '../../../store/settingsStore';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import type { TourActivityStats } from './useTourActivityStats';
const navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(
data: TourActivityStatsResponse | null,
overrides: Partial<TourActivityStats> = {}
): TourActivityStats {
return {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({
place_id: 2,
name: 'Lake',
tour_type: 'kayak',
date: '2026-08-01',
basis: 'track',
track: { place_id: 9, name: 'Watch export' },
distance_km: 8.5,
ascent_m: null,
descent_m: null,
}),
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
],
uncounted: { unassigned: 2, undated: 0 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('TourActivityStatsDialog', () => {
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
const list = screen.getByTestId('tour-activity-list');
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
expect(
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
).toHaveTextContent('3');
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
await user.click(screen.getByRole('button', { name: 'Count again' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
});
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
});
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
expect(navigate).toHaveBeenCalledWith('/trips/5');
});
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /Period/ }));
await user.click(screen.getByRole('button', { name: 'This year' }));
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
await user.click(screen.getByRole('button', { name: /Type/ }));
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
expect(stats.setType).toHaveBeenCalledWith('kayak');
});
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
rerender(
<TourActivityStatsDialog
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
open
onClose={onClose}
/>
);
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
await user.click(closeButton as HTMLElement);
expect(onClose).toHaveBeenCalled();
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,192 @@
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
import { Footprints } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
import { TourActivityEntryRow } from './TourActivityEntryRow';
import {
ActivityFilters,
ActivityStatusLine,
BasisBadges,
distanceReading,
heightReading,
MeasurePills,
partialHint,
typeIcon,
UncountedBadges,
useDistanceUnit,
} from './tourActivityParts';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
const sums = [
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
];
for (const { label, sum, reading } of sums) {
if (!reading) continue;
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
fields.push({ label, value: reading.text, info });
}
return fields;
}
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const Icon = typeIcon(type);
const distance = distanceReading(totals, unit);
const ascent = heightReading(totals.ascentM, unit);
return (
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
<span className="relative flex-none">
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
<Icon size={14} strokeWidth={1.9} />
</span>
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
style={fs(9)}
>
{totals.count}
</span>
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t(`tourTypes.${type}`)}
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
</span>
<span className="flex flex-none items-center gap-1.5">
<MeasurePills
distance={distance?.text ?? null}
ascent={ascent?.text ?? null}
testId={`tour-activity-type-${type}`}
/>
</span>
</li>
);
}
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
function StatsBody({ stats }: { stats: TourActivityStats }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const { summary, data } = stats;
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
const showTypes = stats.type === 'all' && summary.types.length > 1;
return (
<>
<div className="flex flex-col gap-2.5">
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
</div>
{showTypes && (
<DialogSection label={t('tours.stats.byType')}>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{summary.types.map(({ type, ...totals }) => (
<TypeLine key={type} type={type} totals={totals} />
))}
</ul>
</DialogSection>
)}
<DialogSection
label={t('tours.mode.tours')}
action={
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{summary.done.length}
</span>
}
>
{summary.done.length === 0 ? (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
</p>
) : (
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
{summary.done.map((entry) => (
<TourActivityEntryRow
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
entry={entry}
busy={stats.pending.has(entry.place_id)}
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
/>
))}
</ul>
)}
</DialogSection>
</>
);
}
/**
* STA-02: the user's tour activities in full, opened from the Atlas: the
* period and type on the head band, the sums with what they rest on, the
* split by type, and every tour done in the period, each one able to be left
* out (a tour that fell through) and counted again without touching its plan.
*/
export function TourActivityStatsDialog({
stats,
open,
onClose,
}: {
stats: TourActivityStats;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="editor"
align="top"
header={
<DialogHeader
tile={
<DialogTile>
<Footprints size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('tours.stats.title')}
sub={t('tours.stats.sub')}
subWraps
pills={<ActivityFilters stats={stats} />}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
}
>
<StatsBody stats={stats} />
</DialogShell>
);
}
@@ -0,0 +1,269 @@
import type { TourActivityBasis, TourActivityStatsEntry, TourActivityTotals } from '@trek/shared';
import {
AlertTriangle,
ArrowUp,
CalendarClock,
CalendarDays,
CalendarRange,
Footprints,
Layers,
Loader2,
Ruler,
WifiOff,
type LucideIcon,
} from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { periodYear, sumReading, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The distance unit the user reads in. */
export function useDistanceUnit(): DistanceUnit {
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
}
/** What a period is called in the filter. */
function periodLabel(period: ActivityPeriod, t: Translate): string {
if (period === 'all') return t('tours.stats.period.all');
if (period === 'thisYear') return t('tours.stats.period.thisYear');
if (period === 'last12') return t('tours.stats.period.last12');
return String(periodYear(period));
}
/** What a type is called in the filter. */
export function typeLabel(type: ActivityType, t: Translate): string {
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
}
/** The icon a type is drawn with; every type at once is a stack. */
export function typeIcon(type: ActivityType): LucideIcon {
return type === 'all' ? Layers : tourDayRowIcon(type);
}
/** The period and type filters, as head band pills or quiet ones in a panel. */
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
const { t } = useTranslation();
const typeOption = (type: ActivityType) => ({
value: type,
label: typeLabel(type, t),
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
});
return (
<>
<PillSelect
quiet={quiet}
label={t('tours.stats.period')}
value={stats.period}
options={stats.periods.map((period) => ({
value: period,
label: periodLabel(period, t),
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
}))}
fallback={{ label: periodLabel(stats.period, t) }}
onChange={stats.setPeriod}
/>
<PillSelect
quiet={quiet}
label={t('tours.filter.type')}
value={stats.type}
options={stats.types.map(typeOption)}
fallback={typeOption(stats.type)}
onChange={stats.setType}
/>
</>
);
}
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
}
/** A height sum as said, the same way. */
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
return sumReading(sum, (metres) => formatElevation(metres, unit));
}
/** How a short sum explains itself: known for some of the tours only. */
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
}
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
export function MeasurePills({
distance,
ascent,
testId,
}: {
distance: string | null;
ascent: string | null;
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
testId: string;
}) {
const { t } = useTranslation();
return (
<>
{distance !== null && (
<TourDayRowPill
icon={Ruler}
value={distance}
label={`${t('tours.detail.distance')}: ${distance}`}
testId={`${testId}-distance`}
/>
)}
{ascent !== null && (
<TourDayRowPill
icon={ArrowUp}
value={ascent}
label={`${t('tours.detail.ascent')}: ${ascent}`}
testId={`${testId}-ascent`}
/>
)}
</>
);
}
/**
* What the sums rest on, each as a figure badge: how many tours count with
* their planned values and how many with a recorded track. The planned values
* are never passed off as measured, so they always say so.
*/
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
const { t } = useTranslation();
return (
<>
{byBasis.plan > 0 && (
<FigureBadge
testId="tour-activity-basis-plan"
label={t('tours.stats.basisPlan')}
value={String(byBasis.plan)}
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t('tours.stats.basisPlanHint')}
/>
)}
{byBasis.track > 0 && (
<FigureBadge
testId="tour-activity-basis-track"
label={t('tours.stats.basisTrack')}
value={String(byBasis.track)}
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
info={t('tours.stats.basisTrackCountHint')}
/>
)}
</>
);
}
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
export function UncountedBadges({
upcoming,
unassigned,
undated,
}: {
upcoming: number;
unassigned: number;
undated: number;
}) {
const { t } = useTranslation();
const badges = [
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
{
key: 'unassigned',
value: unassigned,
labelKey: 'tours.stats.unassigned',
infoKey: 'tours.stats.unassignedHint',
},
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
];
return (
<>
{badges
.filter((badge) => badge.value > 0)
.map((badge) => (
<FigureBadge
key={badge.key}
testId={`tour-activity-${badge.key}`}
label={t(badge.labelKey)}
value={String(badge.value)}
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t(badge.infoKey)}
/>
))}
</>
);
}
/**
* A tour's type as a round tile; a tour done with a recorded track carries
* the footprints at its corner, the way a waypoint's number carries its kind.
*/
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
const { t } = useTranslation();
const Icon = tourDayRowIcon(entry.tour_type);
return (
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
<Icon size={15} strokeWidth={1.9} />
</span>
{entry.basis === 'track' && (
<span
aria-hidden="true"
data-testid="tour-activity-track-corner"
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
>
<Footprints size={9} strokeWidth={2.6} />
</span>
)}
</span>
</Tooltip>
);
}
/**
* Where the read stands while there is nothing to show yet: on its way, refused
* offline, or failed with a way to try again.
*/
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
const { t } = useTranslation();
// Not asked yet: the tab has not been shown, so there is nothing to say.
if (stats.status === 'idle') return null;
if (stats.status === 'offline')
return (
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<WifiOff size={13} strokeWidth={2} className="flex-none" />
{t('common.needsConnection')}
</p>
);
if (stats.status === 'error')
return (
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
{t('tours.stats.unavailable')}
<button
type="button"
onClick={stats.retry}
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{t('common.errorRetry')}
</button>
</p>
);
return (
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
{t('common.loading')}
</p>
);
}
@@ -0,0 +1,91 @@
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
// STA-02: the periods and types the Atlas offers for the user's tour
// activities, and how a sum reads when some tours do not know the value.
import type { TourActivityStatsEntry } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
periodOptions,
periodRange,
periodYear,
splitMeasure,
sumReading,
typeOptions,
} from './tourActivityStatsModel';
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
describe('tourActivityStatsModel', () => {
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
});
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
const entries = [
entry({ date: '2023-05-01' }),
entry({ date: '2025-08-01' }),
entry({ date: '2025-09-01' }),
entry({ date: '2026-03-01' }),
];
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
});
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
const entries = [
entry({ tour_type: 'kayak' }),
entry({ tour_type: 'run' }),
entry({ tour_type: 'hike' }),
entry({ tour_type: 'bike', date: '2026-12-01' }),
];
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'kayak', 'run']);
});
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
expect(periodYear('year:2024')).toBe(2024);
expect(periodYear('thisYear')).toBeNull();
});
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
const km = (value: number) => `${value} km`;
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
});
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
});
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
'all',
'thisYear',
'last12',
'year:2025',
]);
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
});
});
@@ -0,0 +1,90 @@
import {
isTourActivityDone,
plannableTourTypeSchema,
type TourActivityStatsEntry,
type TourActivitySum,
type TourTypeKey,
} from '@trek/shared';
/**
* STA-02: the periods and types the Atlas narrows the user's tour activities
* by, and how a sum reads. Pure, so the hook and its views share one answer.
*/
/** All time, the calendar year, the last twelve months, or one earlier year. */
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
/** One type, or every type at once. */
export type ActivityType = TourTypeKey | 'all';
interface PeriodRange {
from: string | null;
to: string | null;
}
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
const year = Number(today.slice(0, 4));
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
// A year back to the day, today included: the same day a year ago is the first one.
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
if (period.startsWith('year:')) {
const chosen = period.slice(5);
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
}
return { from: null, to: null };
}
/**
* The periods worth offering: all time, this year and the last twelve months,
* then each earlier year a done tour is in, the newest first. A year without
* a tour would only ever show nothing.
*/
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
const thisYear = today.slice(0, 4);
const years = new Set(
entries
.filter((entry) => isTourActivityDone(entry, today))
.map((entry) => entry.date.slice(0, 4))
.filter((year) => year < thisYear)
);
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
return ['all', 'thisYear', 'last12', ...earlier];
}
/** The types a done tour has, in the order tours are planned in, the older types after them. */
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
const planned: readonly string[] = plannableTourTypeSchema.options;
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
return ['all', ...ordered];
}
function rank(order: readonly string[], value: string): number {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
/** The year of a `year:` period, or null for the others. */
export function periodYear(period: ActivityPeriod): number | null {
return period.startsWith('year:') ? Number(period.slice(5)) : null;
}
/**
* A sum as it is said: nothing when nobody knows the value, else the value,
* marked as at least that much when some of the tours lack it.
*/
export function sumReading(
sum: TourActivitySum,
format: (value: number) => string
): { text: string; partial: boolean } | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
}
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
export function splitMeasure(text: string): { value: string; unit: string } {
const at = text.lastIndexOf(' ');
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
}
@@ -0,0 +1,155 @@
// FE-STA2-HOOK-001 to FE-STA2-HOOK-007
// STA-02: the user's own tour activities are read when the tab is first shown,
// narrowed by period and type, and a tour left out shows at once and comes back
// with a message when the server refuses.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityStats } from './useTourActivityStats';
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
tourActivityStatsRepo: repo,
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
const stats: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
],
uncounted: { unassigned: 0, undated: 0 },
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 20, 12));
repo.get.mockResolvedValue(stats);
});
afterEach(() => vi.useRealTimers());
describe('useTourActivityStats', () => {
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
initialProps: { active: false },
});
expect(result.current.status).toBe('idle');
expect(repo.get).not.toHaveBeenCalled();
rerender({ active: true });
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
expect(result.current.summary?.upcoming).toBe(1);
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
});
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.setPeriod('thisYear'));
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
act(() => {
result.current.setPeriod('all');
result.current.setType('kayak');
});
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
});
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('offline'));
repo.get.mockRejectedValueOnce(new Error('boom'));
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.get).toHaveBeenCalledTimes(3);
});
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
let write: Promise<void> = Promise.resolve();
act(() => {
write = result.current.setExcluded(entry(), true);
});
expect(result.current.pending.has(1)).toBe(true);
expect(result.current.summary?.totals.count).toBe(1);
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
await act(async () => {
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
answer({ ...stats, entries });
await write;
});
expect(result.current.pending.size).toBe(0);
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
repo.setExcluded.mockRejectedValue(new Error('nope'));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.pending.size).toBe(0);
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
});
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result, unmount } = renderHook(() => useTourActivityStats(true));
expect(result.current.status).toBe('loading');
unmount();
await act(async () => answer(stats));
expect(result.current.data).toBeNull();
});
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
repo.get.mockReturnValue(new Promise(() => undefined));
repo.setExcluded.mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useTourActivityStats(true));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.data).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,139 @@
import {
summarizeTourActivities,
type TourActivityStatsEntry,
type TourActivityStatsResponse,
type TourActivitySummary,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
import { localIsoDate } from '../../../utils/localDate';
import { useToast } from '../../shared/Toast';
import {
periodOptions,
periodRange,
typeOptions,
type ActivityPeriod,
type ActivityType,
} from './tourActivityStatsModel';
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
export interface TourActivityStats {
status: ActivityStatsStatus;
data: TourActivityStatsResponse | null;
/** The sums of the chosen period and type; null until the first read is there. */
summary: TourActivitySummary | null;
period: ActivityPeriod;
setPeriod: (period: ActivityPeriod) => void;
periods: ActivityPeriod[];
type: ActivityType;
setType: (type: ActivityType) => void;
types: ActivityType[];
/** The tours whose exclusion is on its way to the server. */
pending: ReadonlySet<number>;
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
retry: () => void;
}
/** What the latest read came back with, and the attempt it answered. */
interface Answered {
attempt: number;
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
}
/** Every entry of the tour with the flag as asked, the rest as they were. */
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
return {
...data,
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
};
}
/**
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
* is first shown (and again on retry), never cached on the device; narrowed by
* period and type here, summed by the shared summary the assistant uses too.
* Leaving a tour out shows at once and is undone with a message when the
* server refuses it; the plan of the tour never changes.
*/
export function useTourActivityStats(active: boolean): TourActivityStats {
const { t } = useTranslation();
const toast = useToast();
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
const [answered, setAnswered] = useState<Answered | null>(null);
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
const [period, setPeriod] = useState<ActivityPeriod>('all');
const [type, setType] = useState<ActivityType>('all');
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
const [attempt, setAttempt] = useState(0);
const today = localIsoDate();
useEffect(() => {
if (!active) return;
const controller = new AbortController();
tourActivityStatsRepo.get(controller.signal).then(
(answer) => {
if (controller.signal.aborted) return;
setData(answer);
setAnswered({ attempt, status: 'ready' });
},
(err: unknown) => {
if (controller.signal.aborted) return;
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
}
);
return () => controller.abort();
}, [active, attempt]);
let status: ActivityStatsStatus = 'loading';
if (answered?.attempt === attempt) status = answered.status;
else if (!active && !answered) status = 'idle';
const summary = useMemo(() => {
if (!data) return null;
const range = periodRange(period, today);
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
}, [data, period, type, today]);
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
const setExcluded = useCallback(
async (entry: TourActivityStatsEntry, excluded: boolean) => {
const placeId = entry.place_id;
setPending((current) => new Set(current).add(placeId));
setData((current) => (current ? flagged(current, placeId, excluded) : current));
try {
setData(await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded }));
} catch {
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
toast.error(t('tours.stats.excludeFailed'));
} finally {
setPending((current) => {
const next = new Set(current);
next.delete(placeId);
return next;
});
}
},
[t, toast]
);
const retry = useCallback(() => setAttempt((count) => count + 1), []);
return {
status,
data,
summary,
period,
setPeriod,
periods,
type,
setType,
types,
pending,
setExcluded,
retry,
};
}
+28
View File
@@ -8,6 +8,7 @@ import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildSettings } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { useAddonStore } from '../store/addonStore';
import AtlasPage from './AtlasPage';
import { maplibreGL } from '@maplibre/maplibre-gl-leaflet';
@@ -1813,4 +1814,31 @@ describe('AtlasPage', () => {
expect(screen.getByText('Machu Picchu')).toBeInTheDocument();
});
});
describe('FE-PAGE-ATLAS-052: the Tours tab shows the own tour activities (STA-02)', () => {
it('offers the tab only with the Tours addon and reads the activities once it is opened', async () => {
let reads = 0;
server.use(
http.get('/api/tours/activity-stats', () => {
reads += 1;
return HttpResponse.json({ entries: [], uncounted: { unassigned: 0, undated: 0 } });
}),
);
const first = render(<AtlasPage />);
await waitFor(() => expect(screen.getByText('Bucket List')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'Tours' })).not.toBeInTheDocument();
first.unmount();
seedStore(useAddonStore, {
addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: 'route', enabled: true }],
});
const user = userEvent.setup();
render(<AtlasPage />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Tours' })).toBeInTheDocument());
expect(reads).toBe(0);
await user.click(screen.getByRole('button', { name: 'Tours' }));
await waitFor(() => expect(screen.getByText('No completed tours yet')).toBeInTheDocument());
expect(reads).toBe(1);
});
});
});
+22 -8
View File
@@ -4,7 +4,7 @@ import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import EmptyState from '../components/shared/EmptyState'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check, ArrowLeft, ChevronRight, type LucideIcon } from 'lucide-react'
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check, ArrowLeft, ChevronRight, Footprints, type LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../types'
import { Tooltip } from '../components/shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
@@ -18,6 +18,8 @@ import AtlasCountrySearch from './atlas/AtlasCountrySearch'
import AtlasLayerToggle from './atlas/AtlasLayerToggle'
import HelpAnchor from '../components/Help/HelpAnchor'
import AtlasCountryPlaces from '../components/Atlas/AtlasCountryPlaces'
import { AtlasTourActivityPane } from '../components/Atlas/tourActivity/AtlasTourActivityPane'
import { useAddonStore } from '../store/addonStore'
import { getIntlLanguage } from '@trek/shared'
// Fixed ids rather than useId: the page body stays free of hooks, and there is only one atlas.
@@ -419,8 +421,9 @@ interface SidebarContentProps {
onTripClick: (id: number) => void
onUnmarkCountry?: (code: string) => void
bucketList: any[]
bucketTab: 'stats' | 'bucket'
setBucketTab: (tab: 'stats' | 'bucket') => void
/** The panel's tab: the travel numbers, the bucket list, or the user's own tours (STA-02, with the Tours addon). */
bucketTab: 'stats' | 'bucket' | 'tours'
setBucketTab: (tab: 'stats' | 'bucket' | 'tours') => void
showBucketAdd: boolean
setShowBucketAdd: (v: boolean) => void
bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string }
@@ -445,6 +448,7 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPlaces, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const statsContentRef = useRef<HTMLDivElement>(null)
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
@@ -470,7 +474,11 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
// Tab switcher
const tabBar = (
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', marginBottom: 4 }}>
{[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => (
{[
{ id: 'stats', label: t('atlas.statsTab'), icon: Globe },
{ id: 'bucket', label: t('atlas.bucketTab'), icon: Star },
...(toursEnabled ? [{ id: 'tours', label: t('tours.mode.tours'), icon: Footprints }] : []),
].map(tab => (
<button type="button" key={tab.id} onClick={() => setBucketTab(tab.id as any)}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
@@ -486,7 +494,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
</div>
)
if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') {
if (countries.length === 0 && !lastTrip && bucketTab === 'stats') {
return (
<>
{tabBar}
@@ -670,9 +678,9 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
return (
<>
{tabBar}
{/* Both tabs always rendered so the wider one sets the panel width */}
{/* Every tab always rendered so the widest one sets the panel width */}
<div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div style={bucketTab !== 'stats' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div ref={statsContentRef} className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
@@ -799,9 +807,15 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
)}
</div>
</div>
<div style={bucketTab === 'stats' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div style={bucketTab !== 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
{bucketContent}
</div>
{/* STA-02: the user's own tours, read only once the tab is first shown. */}
{toursEnabled && (
<div className="flex justify-center" style={bucketTab !== 'tours' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<AtlasTourActivityPane active={bucketTab === 'tours'} maxWidth={statsWidth} />
</div>
)}
</div>
</>
)
+1 -1
View File
@@ -208,7 +208,7 @@ export function useAtlas() {
const [bucketSearching, setBucketSearching] = useState(false)
const [bucketPoiMonth, setBucketPoiMonth] = useState(0)
const [bucketPoiYear, setBucketPoiYear] = useState(0)
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats')
const [bucketTab, setBucketTab] = useState<'stats' | 'bucket' | 'tours'>('stats')
const bucketMarkersRef = useRef<any>(null)
const [atlas_country_search, set_atlas_country_search] = useState('')
@@ -0,0 +1,94 @@
// FE-STA2-REPO-001 to FE-STA2-REPO-004
// STA-02: the user's own tour activities are read live and parsed against the
// shared contract; offline, nothing is requested at all.
import type { TourActivityStatsResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from './tourActivityStatsRepo';
const BASE = '/api/tours/activity-stats';
const stats: TourActivityStatsResponse = {
entries: [
{
place_id: 7,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12.5,
ascent_m: 800,
descent_m: 790,
excluded: false,
},
],
uncounted: { unassigned: 1, undated: 0 },
};
function setOnline(value: boolean): void {
Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true });
}
beforeEach(() => {
setOnline(true);
_resetNetworkMode();
});
afterEach(() => {
_resetNetworkMode();
setOnline(true);
});
describe('tourActivityStatsRepo', () => {
it('FE-STA2-REPO-001: get() reads the sums and parses them', async () => {
server.use(http.get(BASE, () => HttpResponse.json(stats)));
expect(await tourActivityStatsRepo.get()).toEqual(stats);
});
it('FE-STA2-REPO-002: setExcluded() sends the tour and the choice and answers the new sums', async () => {
let sent: unknown = null;
server.use(
http.put(`${BASE}/exclusions`, async ({ request }) => {
sent = await request.json();
return HttpResponse.json({ ...stats, entries: [{ ...stats.entries[0], excluded: true }] });
})
);
const answer = await tourActivityStatsRepo.setExcluded({ place_id: 7, excluded: true });
expect(sent).toEqual({ place_id: 7, excluded: true });
expect(answer.entries[0]?.excluded).toBe(true);
});
it('FE-STA2-REPO-003: offline, by the browser or by the switch, nothing is requested', async () => {
let reached = false;
server.use(
http.all(`${BASE}*`, () => {
reached = true;
return HttpResponse.json(stats);
})
);
setOnline(false);
await expect(tourActivityStatsRepo.get()).rejects.toBeInstanceOf(TourActivityStatsOfflineError);
setOnline(true);
setForcedOffline(true);
await expect(tourActivityStatsRepo.setExcluded({ place_id: 7, excluded: true })).rejects.toBeInstanceOf(
TourActivityStatsOfflineError
);
expect(reached).toBe(false);
});
it('FE-STA2-REPO-004: an answer outside the contract fails in the repo, naming the field', async () => {
const broken = { ...stats, entries: [{ ...stats.entries[0], basis: 'guess' }] };
server.use(http.get(BASE, () => HttpResponse.json(broken)));
const error = await tourActivityStatsRepo.get().catch((err: unknown) => err);
expect((error as { issues?: Array<{ path: Array<string | number> }> }).issues?.[0]?.path).toEqual([
'entries',
0,
'basis',
]);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { tourActivityStatsResponseSchema, type TourActivityExclusionRequest } from '@trek/shared';
import { tourActivityStatsApi } from '../api/tourActivityStats';
import { isEffectivelyOffline } from '../sync/networkMode';
/**
* STA-02: the user's own tour activities for the Atlas.
*
* **Deliberately online-only, and no Dexie table.** The sums run across every
* trip the user can open, most of which are not on the device, and the Atlas
* they belong to reads its statistics live as well; a copy of them would be a
* stale one. Leaving a tour out is online-only for the same reason: the answer
* is the new sums, which only the server can give. The precedent in this
* directory is `dawarichRepo`.
*
* Offline means `isEffectivelyOffline()`, so the manual switch counts, and the
* request is never built. Answers are parsed against the shared schema rather
* than cast.
*/
export class TourActivityStatsOfflineError extends Error {
constructor() {
super('Tour activities need a connection');
this.name = 'TourActivityStatsOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TourActivityStatsOfflineError();
}
export const tourActivityStatsRepo = {
async get(signal?: AbortSignal) {
requireOnline();
return tourActivityStatsResponseSchema.parse(await tourActivityStatsApi.get(signal));
},
async setExcluded(body: TourActivityExclusionRequest) {
requireOnline();
return tourActivityStatsResponseSchema.parse(await tourActivityStatsApi.setExcluded(body));
},
};
+1
View File
@@ -109,6 +109,7 @@
"TodoCategoryAssignees": "todo",
"TodoItems": "todo",
"TourActivities": "tours",
"TourActivityExclusions": "tours",
"TourChangeSteps": "tours",
"TourObstacleReviews": "tours",
"TourStationVisits": "tours",
@@ -0,0 +1,29 @@
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourActivityExclusionsRepository } from '../repositories/TourActivityExclusions.repository';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
export class TourActivityExclusions {
[EntityRepositoryType]?: TourActivityExclusionsRepository;
id!: number & Opt;
user!: Ref<Users>;
user_id!: number;
place!: Ref<Tours>;
place_id!: number;
excluded_at!: string & Opt;
}
export const TourActivityExclusionsSchema = defineEntity({
class: TourActivityExclusions,
repository: () => TourActivityExclusionsRepository,
uniques: [{ properties: ['user', 'place'] }],
properties: {
id: p.integer().primary(),
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(),
user_id: p.integer().persist(false),
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden().index('idx_tour_activity_exclusions_place'),
place_id: p.integer().persist(false).index('idx_tour_activity_exclusions_place'),
excluded_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
},
});
+3
View File
@@ -3,6 +3,7 @@ import { ToursRepository } from '../repositories/Tours.repository';
import { DbTimestampType } from '../types';
import { Places } from './Places.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourChangeSteps } from './TourChangeSteps.entity';
import { TourObstacleReviews } from './TourObstacleReviews.entity';
import { TourSources } from './TourSources.entity';
@@ -52,6 +53,7 @@ export class Tours {
tourTypeRef!: Ref<TourTypes>;
tour_activities_collection = new Collection<TourActivities>(this);
tour_activities_collection1 = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_change_steps_collection = new Collection<TourChangeSteps>(this);
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -187,6 +189,7 @@ export const ToursSchema = defineEntity({
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('place').hidden(),
tour_activities_collection1: () => p.oneToMany(TourActivities).mappedBy('trackPlace').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('place').hidden(),
tour_change_steps_collection: () => p.oneToMany(TourChangeSteps).mappedBy('place').hidden(),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+3
View File
@@ -52,6 +52,7 @@ import { Tags } from './Tags.entity';
import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity';
import { TodoItems } from './TodoItems.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
@@ -180,6 +181,7 @@ export class Users {
todo_category_assignees_collection = new Collection<TodoCategoryAssignees>(this);
todo_items_collection = new Collection<TodoItems>(this);
tour_activities_collection = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
@@ -326,6 +328,7 @@ export const UsersSchema = defineEntity({
todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(),
todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
+2
View File
@@ -108,6 +108,7 @@ import { TagsSchema } from './Tags.entity';
import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
import { TodoItemsSchema } from './TodoItems.entity';
import { TourActivitiesSchema } from './TourActivities.entity';
import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity';
import { TourChangeStepsSchema } from './TourChangeSteps.entity';
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
import { TourSourcesSchema } from './TourSources.entity';
@@ -255,6 +256,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TodoCategoryAssigneesSchema,
TodoItemsSchema,
TourActivitiesSchema,
TourActivityExclusionsSchema,
TourChangeStepsSchema,
TourObstacleReviewsSchema,
TourSourcesSchema,
@@ -0,0 +1,10 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TourActivityExclusionsTable {
id: InsertOptional<number>;
user_id: number;
place_id: number;
excluded_at: InsertOptional<string>;
}
+2
View File
@@ -114,6 +114,7 @@ import type { TagsTable } from './Tags.table';
import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table';
import type { TodoItemsTable } from './TodoItems.table';
import type { TourActivitiesTable } from './TourActivities.table';
import type { TourActivityExclusionsTable } from './TourActivityExclusions.table';
import type { TourChangeStepsTable } from './TourChangeSteps.table';
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
import type { TourSourcesTable } from './TourSources.table';
@@ -267,6 +268,7 @@ export interface DB {
todo_category_assignees: TodoCategoryAssigneesTable;
todo_items: TodoItemsTable;
tour_activities: TourActivitiesTable;
tour_activity_exclusions: TourActivityExclusionsTable;
tour_change_steps: TourChangeStepsTable;
tour_obstacle_reviews: TourObstacleReviewsTable;
tour_sources: TourSourcesTable;
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* STA-02: a user can leave a tour out of their own activity sums in the Atlas,
* a tour that fell through, without touching the tour: its line, its days and
* its measures stay as they are, and nobody else's sums change. One row per
* user and tour (`UNIQUE(user_id, place_id)`); deleting either takes the row
* with it.
*/
export class Migration20200101045910_a_tour_can_be_left_out_of_ones_activities extends Migration {
override name = 'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_activity_exclusions (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
excluded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, place_id)
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_activity_exclusions_place ON tour_activity_exclusions(place_id)');
}
}
@@ -2,7 +2,10 @@ import type { TourActivities } from '../entities/TourActivities.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type ActivitiesDB = Pick<DB, 'tour_activities' | 'tours' | 'places' | 'day_assignments'>;
type ActivitiesDB = Pick<
DB,
'tour_activities' | 'tours' | 'places' | 'day_assignments' | 'days' | 'trips' | 'assignment_participants'
>;
/** One assignment of a recording to a tour, as the trip's tour list names it. */
export interface TourActivityRefRow {
@@ -30,6 +33,40 @@ export interface TourActivityRow extends TourActivityRefRow {
track_ended_at: string | null;
}
/**
* STA-02: one tour of a trip the user can open, once per day assignment it has
* (once with `assignment_id` NULL when it is on no day), with what the activity
* sums need: its plan's measures, the day's date, the trip's owner, and who the
* execution names as taking part (`participant_count`, and `joined` 1 when the
* user is one of them).
*/
export interface TourStatsExecutionRow {
place_id: number;
name: string;
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
derived_from_place_id: number | null;
trip_id: number;
trip_title: string;
trip_owner_id: number;
assignment_id: number | null;
date: string | null;
participant_count: number;
joined: number;
}
/** STA-02: a recording assigned to a tour, with the line its values are read from. */
export interface TourStatsTrackRow {
place_id: number;
track_place_id: number;
assignment_id: number | null;
track_name: string;
track_route_geometry: string | null;
track_recorded_route_geometry: string | null;
}
/** What an assignment writes: the tour, its recording, the execution and who confirmed it. */
interface TourActivityInsert {
place_id: number;
@@ -130,6 +167,81 @@ export class TourActivitiesRepository extends TrekRepository<TourActivities> {
.execute();
}
/**
* STA-02: every tour of the given trips with each of its day assignments, for
* the user's activity sums. The day is read only when it is a day of the
* tour's own trip. Never a trip outside `trip_ids`, which the caller resolved
* from the user's own access.
*/
async listExecutionsForStats(trip_ids: readonly number[], user_id: number): Promise<TourStatsExecutionRow[]> {
if (trip_ids.length === 0) return [];
const rows = await this.db()
.selectFrom('tours as t')
.innerJoin('places as p', 'p.id', 't.place_id')
.innerJoin('trips as tr', 'tr.id', 'p.trip_id')
.leftJoin('day_assignments as da', 'da.place_id', 'p.id')
.leftJoin('days as d', (join) => join.onRef('d.id', '=', 'da.day_id').onRef('d.trip_id', '=', 'p.trip_id'))
.select((eb) => [
'p.id as place_id',
'p.name as name',
't.tour_type as tour_type',
't.distance as distance',
't.elevation_gain as elevation_gain',
't.elevation_loss as elevation_loss',
't.derived_from_place_id as derived_from_place_id',
'tr.id as trip_id',
'tr.title as trip_title',
'tr.user_id as trip_owner_id',
'da.id as assignment_id',
'd.date as date',
eb
.selectFrom('assignment_participants as ap')
.select(eb.fn.countAll<number>().as('n'))
.whereRef('ap.assignment_id', '=', 'da.id')
.as('participant_count'),
eb
.exists(
eb
.selectFrom('assignment_participants as me')
.select('me.id')
.whereRef('me.assignment_id', '=', 'da.id')
.where('me.user_id', '=', user_id),
)
.as('joined'),
])
.where('p.trip_id', 'in', [...trip_ids])
.orderBy('p.id', 'asc')
.orderBy('da.id', 'asc')
.execute();
return rows.map((row) => ({
...row,
participant_count: Number(row.participant_count ?? 0),
joined: Number(row.joined),
}));
}
/** STA-02: the recordings assigned to tours of the given trips, each with its line, both tours in the same trip. */
async listTracksForStats(trip_ids: readonly number[]): Promise<TourStatsTrackRow[]> {
if (trip_ids.length === 0) return [];
return await this.db()
.selectFrom('tour_activities as a')
.innerJoin('places as p', 'p.id', 'a.place_id')
.innerJoin('places as tp', 'tp.id', 'a.track_place_id')
.innerJoin('tours as tt', 'tt.place_id', 'a.track_place_id')
.select([
'a.place_id as place_id',
'a.track_place_id as track_place_id',
'a.assignment_id as assignment_id',
'tp.name as track_name',
'tp.route_geometry as track_route_geometry',
'tt.recorded_route_geometry as track_recorded_route_geometry',
])
.where('p.trip_id', 'in', [...trip_ids])
.whereRef('tp.trip_id', '=', 'p.trip_id')
.orderBy('a.id', 'asc')
.execute();
}
/** Writes one assignment and answers its id. */
async insertActivity(input: TourActivityInsert): Promise<number> {
const row = await this.db().insertInto('tour_activities').values(input).returning('id').executeTakeFirstOrThrow();
@@ -0,0 +1,46 @@
import type { TourActivityExclusions } from '../entities/TourActivityExclusions.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type ExclusionsDB = Pick<DB, 'tour_activity_exclusions'>;
/**
* `tour_activity_exclusions` (STA-02): the tours a user left out of their own
* activity sums. Personal: every statement names the user, so one person's
* choice never reaches anyone else's sums. The caller checks that the user
* can open the tour's trip before writing.
*/
export class TourActivityExclusionsRepository extends TrekRepository<TourActivityExclusions> {
private db() {
return this.kysely<ExclusionsDB>();
}
/** The tours the user left out, by place id. */
async listPlaceIds(user_id: number): Promise<number[]> {
const rows = await this.db()
.selectFrom('tour_activity_exclusions')
.select('place_id')
.where('user_id', '=', user_id)
.orderBy('place_id', 'asc')
.execute();
return rows.map((row) => row.place_id);
}
/** Leaves the tour out of the user's sums; a second call changes nothing. */
async exclude(user_id: number, place_id: number): Promise<void> {
await this.db()
.insertInto('tour_activity_exclusions')
.values({ user_id, place_id })
.onConflict((oc) => oc.columns(['user_id', 'place_id']).doNothing())
.execute();
}
/** Counts the tour again; nothing to do when it was not left out. */
async include(user_id: number, place_id: number): Promise<void> {
await this.db()
.deleteFrom('tour_activity_exclusions')
.where('user_id', '=', user_id)
.where('place_id', '=', place_id)
.execute();
}
}
@@ -0,0 +1,5 @@
import { tourActivityExclusionRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourActivityExclusionDto extends createZodDto(tourActivityExclusionRequestSchema) {}
@@ -0,0 +1,37 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TourActivityExclusionDto } from './dto/tour-activity-stats.dto';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { tourActivityStatsResponseSchema } from '@trek/shared';
/**
* STA-02 /api/tours/activity-stats: the user's own tour activities across
* their trips, for the Atlas, and leaving one out of them. Not trip-scoped,
* so there is no TripAccessGuard; the service bounds every answer by the
* user's own trips. Leaving a tour out changes only the user's own sums, so it
* asks for no trip permission beyond being able to open the trip.
*/
@Controller('api/tours/activity-stats')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourActivityStatsController {
constructor(private readonly stats: TourActivityStatsService) {}
@Get()
@ResponseContract(tourActivityStatsResponseSchema)
async list(@CurrentUser() user: User) {
return await this.stats.stats(user.id);
}
@Put('exclusions')
@ResponseContract(tourActivityStatsResponseSchema)
async setExcluded(@CurrentUser() user: User, @Body() body: TourActivityExclusionDto) {
return await this.stats.setExcluded(user.id, body);
}
}
@@ -0,0 +1,184 @@
/**
* STA-02: the pure half of the user's activity sums. The rows are every tour
* of the trips the user can open, once per day it is on, and the recordings
* assigned to them; what comes out is the list of the user's done (or still
* upcoming) tours the shared summary adds up, see `tour-activity-stats.schema`
* in @trek/shared for the rules this follows.
*/
import type { TourStatsExecutionRow, TourStatsTrackRow } from '../../db/repositories/TourActivities.repository';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import type { TourActivityStatsEntry, TourActivityStatsResponse, TourTypeKey } from '@trek/shared';
/** One tour with its executions, as the rows give it. */
interface TourOfStats {
row: TourStatsExecutionRow;
executions: TourStatsExecutionRow[];
}
/** One execution of a tour that is the user's and on a day with a date. */
interface DatedExecution {
assignmentId: number;
date: string;
}
interface ActivityStatsInput {
userId: number;
executions: readonly TourStatsExecutionRow[];
tracks: readonly TourStatsTrackRow[];
/** The tours the user left out of their own sums. */
excluded: ReadonlySet<number>;
}
/**
* Whether an execution is the user's own: it names the user among those taking
* part, or names nobody on a trip that is the user's. Being able to open
* someone else's trip alone makes none of its tours the user's.
*/
export function isOwnExecution(row: TourStatsExecutionRow, userId: number): boolean {
if (row.joined === 1) return true;
return row.participant_count === 0 && row.trip_owner_id === userId;
}
/** A trip day's date as a calendar day, null when the day has none. */
function calendarDay(date: string | null): string | null {
const day = date?.slice(0, 10) ?? null;
return day && /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
}
function groupByTour(rows: readonly TourStatsExecutionRow[]): TourOfStats[] {
const tours = new Map<number, TourOfStats>();
for (const row of rows) {
const tour = tours.get(row.place_id) ?? { row, executions: [] };
if (row.assignment_id !== null) tour.executions.push(row);
tours.set(row.place_id, tour);
}
return [...tours.values()];
}
/** The user's executions of a tour, and of those the ones on a dated day, the earliest first. */
function ownExecutions(tour: TourOfStats, userId: number): { own: number; dated: DatedExecution[] } {
const own = tour.executions.filter((row) => isOwnExecution(row, userId));
const dated: DatedExecution[] = [];
for (const row of own) {
const date = calendarDay(row.date);
if (date !== null && row.assignment_id !== null) dated.push({ assignmentId: row.assignment_id, date });
}
dated.sort((a, b) => a.date.localeCompare(b.date) || a.assignmentId - b.assignmentId);
return { own: own.length, dated };
}
/** A measure as the contract carries it: unknown stays null, never a negative. */
function measure(value: number | null): number | null {
return value !== null && Number.isFinite(value) && value >= 0 ? value : null;
}
/** On the water a climb says nothing, so a paddle tour carries no heights, planned or recorded. */
function heights(type: string, gain: number | null, loss: number | null) {
return type === 'kayak' ? { ascent_m: null, descent_m: null } : { ascent_m: measure(gain), descent_m: measure(loss) };
}
function baseEntry(tour: TourOfStats, excluded: ReadonlySet<number>) {
const { row } = tour;
return {
place_id: row.place_id,
name: row.name,
tour_type: row.tour_type as TourTypeKey,
trip: { id: row.trip_id, title: row.trip_title },
excluded: excluded.has(row.place_id),
};
}
/** The plan standing in for the tour: once, on the first of its days, however many there are. */
function planEntry(
tour: TourOfStats,
first: DatedExecution,
dayCount: number,
excluded: ReadonlySet<number>,
): TourActivityStatsEntry {
const { row } = tour;
return {
...baseEntry(tour, excluded),
date: first.date,
basis: 'plan',
track: null,
day_count: dayCount,
distance_km: measure(row.distance),
...heights(row.tour_type, row.elevation_gain, row.elevation_loss),
};
}
/** A recording in the plan's place, measured on its own line: the line it kept if it was planned over since. */
function trackEntry(
tour: TourOfStats,
track: TourStatsTrackRow,
date: string,
excluded: ReadonlySet<number>,
): TourActivityStatsEntry {
const line = parseRouteGeometry(track.track_recorded_route_geometry ?? track.track_route_geometry);
const metrics = computeTourMetrics(line);
return {
...baseEntry(tour, excluded),
date,
basis: 'track',
track: { place_id: track.track_place_id, name: track.track_name },
day_count: 1,
distance_km: line.length >= 2 ? metrics.distanceKm : null,
...heights(tour.row.tour_type, metrics.elevationGainM, metrics.elevationLossM),
};
}
/**
* A tour's entries: one per recording of one of the user's executions (a
* recording on no day counts with the first of them), or else the plan once.
* A recording of someone else's execution is theirs, not the user's.
*/
function entriesOfTour(
tour: TourOfStats,
[first, ...rest]: [DatedExecution, ...DatedExecution[]],
tracks: readonly TourStatsTrackRow[],
excluded: ReadonlySet<number>,
): TourActivityStatsEntry[] {
const dateOf = new Map([first, ...rest].map((execution) => [execution.assignmentId, execution.date]));
const recorded = tracks
.filter((track) => track.place_id === tour.row.place_id)
.map((track) => ({ track, date: track.assignment_id === null ? first.date : dateOf.get(track.assignment_id) }))
.filter((item): item is { track: TourStatsTrackRow; date: string } => item.date !== undefined);
if (recorded.length === 0) return [planEntry(tour, first, rest.length + 1, excluded)];
return recorded.map(({ track, date }) => trackEntry(tour, track, date, excluded));
}
/** What became of one tour: its entries, or why a tour of the user's own trip cannot count. */
type TourOutcome = TourActivityStatsEntry[] | 'unassigned' | 'undated' | null;
function outcomeOf(tour: TourOfStats, input: ActivityStatsInput): TourOutcome {
const { own, dated } = ownExecutions(tour, input.userId);
const [first, ...rest] = dated;
if (first) return entriesOfTour(tour, [first, ...rest], input.tracks, input.excluded);
if (tour.row.trip_owner_id !== input.userId) return null;
if (tour.executions.length === 0) return 'unassigned';
return own > 0 ? 'undated' : null;
}
/**
* The user's activity entries and what of their own trips cannot count. A
* recording is never a tour of its own here, and an import other tours were
* planned from (a whole route and its stages) is left out while one of those
* tours counts, so nothing is added up twice.
*/
export function buildActivityStats(input: ActivityStatsInput): TourActivityStatsResponse {
const recordings = new Set(input.tracks.map((track) => track.track_place_id));
const tours = groupByTour(input.executions).filter((tour) => !recordings.has(tour.row.place_id));
const outcomes = tours.map((tour) => ({ tour, outcome: outcomeOf(tour, input) }));
const stagesCounted = new Set(
outcomes.filter(({ outcome }) => Array.isArray(outcome)).map(({ tour }) => tour.row.derived_from_place_id),
);
const uncounted = { unassigned: 0, undated: 0 };
const entries: TourActivityStatsEntry[] = [];
for (const { tour, outcome } of outcomes) {
if (outcome === null || stagesCounted.has(tour.row.place_id)) continue;
if (Array.isArray(outcome)) entries.push(...outcome);
else uncounted[outcome] += 1;
}
entries.sort((a, b) => b.date.localeCompare(a.date) || a.name.localeCompare(b.name) || a.place_id - b.place_id);
return { entries, uncounted };
}
@@ -0,0 +1,89 @@
import { ADDON_IDS } from '../../addons';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
errorResult,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { NotFoundException } from '@nestjs/common';
import {
summarizeTourActivities,
tourActivityExclusionRequestSchema,
tourActivityStatsQuerySchema,
type TourActivityExclusionRequest,
type TourActivityStatsQuery,
type TourActivityStatsResponse,
} from '@trek/shared';
const query = tourActivityStatsQuerySchema.shape;
const exclusion = tourActivityExclusionRequestSchema.shape;
/** Today as a calendar day in UTC: the assistant has no time zone of the user's to go by. */
function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
/** The sums of the period and type, the tours they are made of, and what of the user's trips cannot count. */
function answer(stats: TourActivityStatsResponse, filter: TourActivityStatsQuery = {}) {
const today = todayUtc();
const summary = summarizeTourActivities(stats.entries, { today, ...filter });
return {
today,
totals: summary.totals,
by_type: summary.types,
tours: summary.done,
upcoming: summary.upcoming,
uncounted: stats.uncounted,
};
}
/**
* STA-02: the user's own tour activities and leaving one out of them, over
* the same service as the Atlas: every trip the user can open, only the
* user's own part of it, and the user's own exclusions.
*/
@McpController()
export class TourActivityStatsMcp {
constructor(
private readonly stats: TourActivityStatsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
@Tool({
name: 'get_tour_activity_stats',
description:
'The tours the user has done, summed: how many, distance in km, ascent and descent in metres, as a whole and by type, for a period (from and to, YYYY-MM-DD, both included) and one type, or all of them. A tour counts once the day it is on is over (today is UTC here), and only where the user took part: a day of the tour names the user among its participants, or names none on a trip the user owns. Merely being able to open a trip of somebody else counts nothing. basis "plan" means the values come from the planning of the trip, not from a measurement, and must be said so; basis "track" means a recorded track linked to the tour replaced them. A tour on several days counts once; a paddle tour sums no heights; null means unknown, never zero. Tours the user left out (excluded true) are listed but not summed. uncounted names the tours of trips the user owns that are on no day or only on days without a date.',
inputSchema: { from: query.from, to: query.to, type: query.type },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async getTourActivityStats(input: TourActivityStatsQuery, ctx: McpContext) {
return ok(answer(await this.stats.stats(ctx.userId), input));
}
@Tool({
name: 'set_tour_activity_excluded',
description:
'Leave a tour out of the activity sums of the user (excluded true), such as a tour that fell through, or count it again (excluded false), once the user asked for it. place_id is the tour as get_tour_activity_stats lists it. Nothing of the tour, its days or its plan changes, and the sums of anybody else stay as they are. Answers the sums of all time as they are then.',
inputSchema: { place_id: exclusion.place_id, excluded: exclusion.excluded },
annotations: TOOL_ANNOTATIONS_WRITE,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'write' },
})
async setTourActivityExcluded(input: TourActivityExclusionRequest, ctx: McpContext) {
try {
return ok(answer(await this.stats.setExcluded(ctx.userId, input)));
} catch (err: unknown) {
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
}
@@ -0,0 +1,52 @@
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { Tours } from '../../db/entities/Tours.entity';
import type { TourActivitiesRepository } from '../../db/repositories/TourActivities.repository';
import type { TourActivityExclusionsRepository } from '../../db/repositories/TourActivityExclusions.repository';
import type { ToursRepository } from '../../db/repositories/Tours.repository';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { buildActivityStats } from './tour-activity-stats.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
/**
* STA-02: the user's own tour activities for the Atlas, across every trip the
* user can open, and leaving one out of them. Reads only, apart from the
* user's own exclusions: no tour, day or plan changes here. Every answer is
* bounded by the trips the user owns or is a member of, read anew for each
* call; within them only the user's own part counts (see
* `buildActivityStats`). REST and MCP call these two methods.
*/
@Injectable()
export class TourActivityStatsService {
constructor(
private readonly membership: TripMembershipService,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
@InjectRepository(TourActivities) private readonly activitiesRepo: TourActivitiesRepository,
@InjectRepository(TourActivityExclusions) private readonly exclusionsRepo: TourActivityExclusionsRepository,
) {}
async stats(userId: number): Promise<TourActivityStatsResponse> {
const tripIds = await this.membership.listAccessibleTripIds(userId);
const [executions, tracks, excluded] = await Promise.all([
this.activitiesRepo.listExecutionsForStats(tripIds, userId),
this.activitiesRepo.listTracksForStats(tripIds),
this.exclusionsRepo.listPlaceIds(userId),
]);
return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) });
}
/**
* Leaves a tour out of the user's sums, or counts it again, and answers the
* sums as they are then. The tour must be in a trip the user can open; any
* other id reads as missing. Only the user's own sums change.
*/
async setExcluded(userId: number, input: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> {
const tripIds = await this.membership.listAccessibleTripIds(userId);
if (!(await this.toursRepo.findForLibrary(tripIds, input.place_id))) throw new NotFoundException('Tour not found');
if (input.excluded) await this.exclusionsRepo.exclude(userId, input.place_id);
else await this.exclusionsRepo.include(userId, input.place_id);
return await this.stats(userId);
}
}
+11
View File
@@ -1,5 +1,6 @@
import { Places } from '../../db/entities/Places.entity';
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity';
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
import { TourSources } from '../../db/entities/TourSources.entity';
@@ -24,6 +25,9 @@ import { CityWalkMcp } from './city-walk.mcp';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
import { TourActivityStatsController } from './tour-activity-stats.controller';
import { TourActivityStatsMcp } from './tour-activity-stats.mcp';
import { TourActivityStatsService } from './tour-activity-stats.service';
import { TourChangePreviewService } from './tour-change-preview.service';
import { TourChangeSaveService } from './tour-change-save.service';
import { TourChangeUndoMcp } from './tour-change-undo.mcp';
@@ -76,6 +80,9 @@ import { Module } from '@nestjs/common';
* step for a later undo (MCP-T03). The recorded tracks assigned to a tour
* (ACT-01) are read with its detail and written through
* TourActivitiesService, over the day assignments the visits repository reads.
* The user's own activity sums across their trips (STA-02) are read by
* TourActivityStatsService, over the trips TripMembershipModule says they can
* open, with the tours the user left out of them.
*/
@Module({
imports: [
@@ -106,6 +113,7 @@ import { Module } from '@nestjs/common';
TourStationVisits,
TourChangeSteps,
TourActivities,
TourActivityExclusions,
Places,
]),
],
@@ -113,6 +121,7 @@ import { Module } from '@nestjs/common';
TourHorseRoutesController,
TourTakeOverController,
TourActivitiesController,
TourActivityStatsController,
TourPoiSearchController,
TourSkiController,
TourTrailGradeController,
@@ -144,6 +153,7 @@ import { Module } from '@nestjs/common';
TourChangeSaveService,
TourChangeUndoService,
TourActivitiesService,
TourActivityStatsService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -151,6 +161,7 @@ import { Module } from '@nestjs/common';
TourChangeMcp,
TourChangeUndoMcp,
TourActivitiesMcp,
TourActivityStatsMcp,
],
})
export class ToursModule {}
@@ -0,0 +1,216 @@
/**
* STA-02 e2e: the user's own tour activities for the Atlas, through the real
* JwtAuthGuard and AddonGuard against a real migrated temp SQLite db. Tours
* are created through the tours API and put on dated days; whose they are
* follows the day's participants and the trip's owner, never mere access.
*
* Fixtures: user 1 owns trip 5 (two dated days, one without a date), user 2 is
* a member, user 3 has no access.
*/
import { db } from '../../src/db/database';
import { AssignmentParticipants } from '../../src/db/entities/AssignmentParticipants.entity';
import { Days } from '../../src/db/entities/Days.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { TourActivityExclusions } from '../../src/db/entities/TourActivityExclusions.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { makeDayAssignment } from '../helpers/factories/itinerary';
import { countRows, deleteRows, insertRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const journeyHooks = {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
};
const realtime = new FakeRealtimeService();
const OWNER = 1;
const MEMBER = 2;
const OUTSIDER = 3;
const TRIP = 5;
const STATS = '/api/tours/activity-stats';
const tourBody = (name: string) => ({
name,
route_geometry: [
[47, 11, 600],
[47.01, 11.01, 700],
],
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
});
describe('Tour activity stats e2e (STA-02)', () => {
let orm: TestOrm;
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
/** A tour of trip 5 through the API, put on the given day. */
const tourOnDay = async (name: string, dayId: number | null) => {
const res = await request(server).post(`/api/trips/${TRIP}/tours`).set(as(OWNER)).send(tourBody(name));
expect(res.status).toBe(201);
const placeId = res.body.tour.place_id as number;
const assignmentId = dayId === null ? null : (await makeDayAssignment(orm, dayId, placeId)).id;
return { placeId, assignmentId };
};
const exclude = (body: unknown, userId = OWNER) =>
request(server).put(`${STATS}/exclusions`).set(as(userId)).send(body);
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 3, username: 'outsider', email: 'out@example.test', role: 'user', password_version: 0 });
await insertRows(orm, Trips, [{ id: TRIP, user: 1, title: 'Alps' }]);
await addTripMember(orm, TRIP, 2);
await insertRows(orm, Days, [
{ id: 3, trip: TRIP, day_number: 1, date: '2026-07-02' },
{ id: 4, trip: TRIP, day_number: 2, date: '2026-07-03' },
{ id: 6, trip: TRIP, day_number: 3 },
]);
app = await build();
server = app.getHttpServer();
});
beforeEach(async () => {
await setAddonEnabled(orm, 'tours', true);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('STA2-E2E-001: the owner gets the planned tours of dated days, on the plan, and what cannot count', async () => {
const ridge = await tourOnDay('Ridge', 3);
await tourOnDay('Undated', 6);
await tourOnDay('Idle', null);
const res = await request(server).get(STATS).set(as(OWNER));
expect(res.status).toBe(200);
expect(res.body.entries).toEqual([
expect.objectContaining({
place_id: ridge.placeId,
name: 'Ridge',
tour_type: 'hike',
trip: { id: TRIP, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
excluded: false,
}),
]);
expect(res.body.uncounted).toEqual({ unassigned: 1, undated: 1 });
});
it('STA2-E2E-002: a member counts only the days naming them; an outsider gets nothing', async () => {
const ridge = await tourOnDay('Ridge', 3);
const lake = await tourOnDay('Lake', 4);
await insertRow(orm, AssignmentParticipants, { assignment: lake.assignmentId, user: MEMBER });
const member = await request(server).get(STATS).set(as(MEMBER));
expect(member.body.entries.map((entry: { place_id: number }) => entry.place_id)).toEqual([lake.placeId]);
expect(member.body.uncounted).toEqual({ unassigned: 0, undated: 0 });
// The owner is not on Lake's day any more, only on Ridge's.
const owner = await request(server).get(STATS).set(as(OWNER));
expect(owner.body.entries.map((entry: { place_id: number }) => entry.place_id)).toEqual([ridge.placeId]);
const outsider = await request(server).get(STATS).set(as(OUTSIDER));
expect(outsider.body).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 } });
});
it('STA2-E2E-003: leaving a tour out flags it for the user alone and counts it again on request', async () => {
const ridge = await tourOnDay('Ridge', 3);
await insertRow(orm, AssignmentParticipants, { assignment: ridge.assignmentId, user: MEMBER });
await insertRow(orm, AssignmentParticipants, { assignment: ridge.assignmentId, user: OWNER });
const left = await exclude({ place_id: ridge.placeId, excluded: true });
expect(left.status).toBe(200);
expect(left.body.entries[0]).toMatchObject({ place_id: ridge.placeId, excluded: true });
const member = await request(server).get(STATS).set(as(MEMBER));
expect(member.body.entries[0]).toMatchObject({ place_id: ridge.placeId, excluded: false });
const back = await exclude({ place_id: ridge.placeId, excluded: false });
expect(back.body.entries[0]).toMatchObject({ excluded: false });
expect(await countRows(orm, TourActivityExclusions)).toBe(0);
});
it('STA2-E2E-004: the gates: 401 without a session, 404 without access or the addon, 400 on a bad body', async () => {
const ridge = await tourOnDay('Ridge', 3);
expect((await request(server).get(STATS)).status).toBe(401);
const body = { place_id: ridge.placeId, excluded: true };
const anonymous = await request(server).put(`${STATS}/exclusions`).send(body);
expect(anonymous.status).toBe(401);
const foreign = await exclude({ place_id: ridge.placeId, excluded: true }, OUTSIDER);
expect(foreign.status).toBe(404);
expect((await exclude({ place_id: ridge.placeId })).status).toBe(400);
expect((await exclude({ place_id: ridge.placeId, excluded: true, note: 'rain' })).status).toBe(400);
expect(await countRows(orm, TourActivityExclusions)).toBe(0);
await setAddonEnabled(orm, 'tours', false);
const off = await request(server).get(STATS).set(as(OWNER));
expect(off.status).toBe(404);
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
});
});
+4 -3
View File
@@ -42,14 +42,15 @@ describe('ALL_ENTITIES', () => {
* + paddle_water_graphs and paddle_region_requests, a region's water network and the regions that wait for an admin
* + tour_station_visits, how far a city walk got on one of its days
* + tour_change_steps, the changes to a tour saved from a confirmed proposal
* + tour_activities, the recorded tracks assigned to a tour)
* + tour_activities, the recorded tracks assigned to a tour
* + tour_activity_exclusions, the tours a user left out of their own activity sums)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 145 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(145);
it('ENT-003: exactly 146 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(146);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -39,7 +39,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + tour_change_steps: id, place_id, proposal_id, base_revision, revision, changes, before_state (a step is
// only ever written whole, by the save of a confirmed change).
// + tour_activities: id, place_id, track_place_id (an assignment always names its tour and its recording).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 507;
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 510;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -73,6 +73,7 @@ const UNNUMBERED = [
'Migration20200101045700_a_tour_records_the_changes_saved_from_a_proposal',
'Migration20200101045710_a_saved_tour_change_can_be_taken_back',
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,130 @@
/**
* `TourActivitiesRepository`, the STA-02 reads: every tour of the trips the
* user can open, once per day it is on, with the day's date, the trip's owner
* and who the day names as taking part; and the recordings assigned to them
* with their lines. Seeded through the ORM factories over the migrated
* snapshot schema.
*/
import { AssignmentParticipants } from '../../../../src/db/entities/AssignmentParticipants.entity';
import { TourActivities } from '../../../../src/db/entities/TourActivities.entity';
import type { TourActivitiesRepository } from '../../../../src/db/repositories/TourActivities.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makeDayAssignment } from '../../../helpers/factories/itinerary';
import { makePlace } from '../../../helpers/factories/places';
import { insertRow } from '../../../helpers/factories/rows';
import { makeTour, makeTourActivity } from '../../../helpers/factories/tours';
import { makeDay, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourActivitiesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourActivities);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
]);
/** A hike on a dated day with a friend named on it, a tour on no day, a recording, and a trip of somebody else. */
async function fixture() {
const { user } = await makeUser(t);
const { user: friend } = await makeUser(t);
const trip = await makeTrip(t, user.id, { title: 'Alps' });
const day = await makeDay(t, trip.id, { date: '2026-07-02' });
const undated = await makeDay(t, trip.id);
const hike = await makePlace(t, trip.id, { name: 'Ridge' });
await makeTour(t, hike.id, { distance: 12.5, elevation_gain: 800, elevation_loss: 790 });
const onDay = await makeDayAssignment(t, day.id, hike.id);
const onUndated = await makeDayAssignment(t, undated.id, hike.id);
await insertRow(t, AssignmentParticipants, { assignment: onDay.id, user: friend.id });
const idle = await makePlace(t, trip.id, { name: 'Idle' });
await makeTour(t, idle.id);
const watch = await makePlace(t, trip.id, { name: 'Watch export', route_geometry: LINE });
await makeTour(t, watch.id, { origin: 'gpx' });
const theirs = await makeTrip(t, friend.id, { title: 'Theirs' });
const theirTour = await makePlace(t, theirs.id, { name: 'Their tour' });
await makeTour(t, theirTour.id);
return { user, friend, trip, day, hike, onDay, onUndated, idle, watch, theirs, theirTour };
}
describe('TourActivitiesRepository stats reads (STA-02)', () => {
it('STA2-REPO-010: lists each tour once per day it is on, once without a day, with the day and the trip', async () => {
const { user, friend, trip, hike, onDay, onUndated, idle, watch } = await fixture();
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.map((row) => [row.place_id, row.assignment_id, row.date])).toEqual([
[hike.id, onDay.id, '2026-07-02'],
[hike.id, onUndated.id, null],
[idle.id, null, null],
[watch.id, null, null],
]);
expect(rows[0]).toMatchObject({
name: 'Ridge',
tour_type: 'hike',
distance: 12.5,
elevation_gain: 800,
elevation_loss: 790,
derived_from_place_id: null,
trip_id: trip.id,
trip_title: 'Alps',
trip_owner_id: user.id,
participant_count: 1,
joined: 0,
});
expect(rows[1]).toMatchObject({ participant_count: 0, joined: 0 });
const asFriend = await repo.listExecutionsForStats([trip.id], friend.id);
expect(asFriend[0]).toMatchObject({ participant_count: 1, joined: 1 });
});
it('STA2-REPO-011: never reaches a trip outside the ids given, and reads nothing for none', async () => {
const { user, trip, theirs, theirTour } = await fixture();
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.some((row) => row.place_id === theirTour.id)).toBe(false);
expect((await repo.listExecutionsForStats([theirs.id], user.id)).map((row) => row.place_id)).toEqual([
theirTour.id,
]);
expect(await repo.listExecutionsForStats([], user.id)).toEqual([]);
});
it('STA2-REPO-012: a day of another trip gives no date', async () => {
const { user, trip, theirs, idle } = await fixture();
const foreignDay = await makeDay(t, theirs.id, { date: '2026-08-01' });
await makeDayAssignment(t, foreignDay.id, idle.id);
const rows = await repo.listExecutionsForStats([trip.id], user.id);
expect(rows.filter((row) => row.place_id === idle.id).map((row) => row.date)).toEqual([null]);
});
it('STA2-REPO-013: lists the recordings of the trips with the lines they were recorded with', async () => {
const { trip, hike, onDay, watch, theirs } = await fixture();
await makeTourActivity(t, hike.id, watch.id, { assignment: onDay.id });
expect(await repo.listTracksForStats([trip.id])).toEqual([
{
place_id: hike.id,
track_place_id: watch.id,
assignment_id: onDay.id,
track_name: 'Watch export',
track_route_geometry: LINE,
track_recorded_route_geometry: null,
},
]);
expect(await repo.listTracksForStats([theirs.id])).toEqual([]);
expect(await repo.listTracksForStats([])).toEqual([]);
});
});
@@ -0,0 +1,89 @@
/**
* `TourActivityExclusionsRepository` (STA-02): the tours a user left out of
* their own activity sums. Personal throughout: one user's rows never reach
* another's reads or writes. Seeded through the ORM factories over the
* migrated snapshot schema.
*/
import { Places } from '../../../../src/db/entities/Places.entity';
import { TourActivityExclusions } from '../../../../src/db/entities/TourActivityExclusions.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { TourActivityExclusionsRepository } from '../../../../src/db/repositories/TourActivityExclusions.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makePlace } from '../../../helpers/factories/places';
import { countRows, deleteRows, findRow } from '../../../helpers/factories/rows';
import { makeTour } from '../../../helpers/factories/tours';
import { makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourActivityExclusionsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourActivityExclusions);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** Two users, one trip with two tours. */
async function fixture() {
const { user } = await makeUser(t);
const { user: friend } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const ridge = await makePlace(t, trip.id, { name: 'Ridge' });
await makeTour(t, ridge.id);
const lake = await makePlace(t, trip.id, { name: 'Lake' });
await makeTour(t, lake.id, { tourTypeRef: 'kayak' });
return { user, friend, ridge, lake };
}
describe('TourActivityExclusionsRepository', () => {
it('STA2-REPO-001: exclude writes one row per user and tour, a second call changes nothing', async () => {
const { user, ridge, lake } = await fixture();
await repo.exclude(user.id, lake.id);
await repo.exclude(user.id, ridge.id);
await repo.exclude(user.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([ridge.id, lake.id].sort((a, b) => a - b));
expect(await countRows(t, TourActivityExclusions, { user: user.id })).toBe(2);
expect((await findRow(t, TourActivityExclusions, { user: user.id, place: ridge.id }))?.excluded_at).toMatch(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
);
});
it('STA2-REPO-002: one user leaving a tour out leaves it in for everybody else', async () => {
const { user, friend, ridge } = await fixture();
await repo.exclude(user.id, ridge.id);
expect(await repo.listPlaceIds(friend.id)).toEqual([]);
await repo.include(friend.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([ridge.id]);
});
it('STA2-REPO-003: include counts the tour again, and is a no-op for a tour never left out', async () => {
const { user, ridge, lake } = await fixture();
await repo.exclude(user.id, ridge.id);
await repo.include(user.id, lake.id);
await repo.include(user.id, ridge.id);
expect(await repo.listPlaceIds(user.id)).toEqual([]);
});
it('STA2-REPO-004: deleting the tour or the user takes the row along', async () => {
const { user, friend, ridge, lake } = await fixture();
await repo.exclude(user.id, ridge.id);
await repo.exclude(friend.id, lake.id);
await deleteRows(t, Places, { id: ridge.id });
expect(await repo.listPlaceIds(user.id)).toEqual([]);
await deleteRows(t, Users, { id: friend.id });
expect(await countRows(t, TourActivityExclusions, {})).toBe(0);
});
});
@@ -0,0 +1,64 @@
/**
* TourActivityStatsController (STA-02): route metadata and delegation. The
* sums and the access check live in TourActivityStatsService, which the MCP
* tools call too; the service suite and the e2e suite hold those.
*/
import 'reflect-metadata';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { TourActivityStatsController } from '../../../src/nest/tours/tour-activity-stats.controller';
import type { TourActivityStatsService } from '../../../src/nest/tours/tour-activity-stats.service';
import type { User } from '../../../src/types';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { tourActivityExclusionRequestSchema } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/tours/tour-activity-stats.service', () => ({ TourActivityStatsService: class {} }));
vi.mock('../../../src/nest/auth-core/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
const answer = { entries: [], uncounted: { unassigned: 0, undated: 0 } };
describe('TourActivityStatsController', () => {
it('STA2-CTL-001: sits behind the addon and auth guards, not under a trip', () => {
expect(Reflect.getMetadata(PATH_METADATA, TourActivityStatsController)).toBe('api/tours/activity-stats');
expect(Reflect.getMetadata(GUARDS_METADATA, TourActivityStatsController)).toEqual([AddonGuard, JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, TourActivityStatsController)).toEqual({
addonId: 'tours',
label: 'Tours',
});
});
it('STA2-CTL-002: GET reads the sums and PUT exclusions writes, both answering 200 without a trip permission', () => {
const routes = [
['list', RequestMethod.GET, '/'],
['setExcluded', RequestMethod.PUT, 'exclusions'],
] as const;
for (const [handler, method, path] of routes) {
const fn = TourActivityStatsController.prototype[handler];
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(HTTP_CODE_METADATA, fn)).toBeUndefined();
}
});
it('STA2-CTL-003: GET hands the user to the service', async () => {
const service = { stats: vi.fn().mockResolvedValue(answer) } as unknown as TourActivityStatsService;
expect(await new TourActivityStatsController(service).list({ id: 3 } as User)).toBe(answer);
expect(service.stats).toHaveBeenCalledWith(3);
});
it('STA2-CTL-004: PUT hands the user and the validated body to the service', async () => {
const service = { setExcluded: vi.fn().mockResolvedValue(answer) } as unknown as TourActivityStatsService;
const body = tourActivityExclusionRequestSchema.parse({ place_id: 9, excluded: true });
expect(await new TourActivityStatsController(service).setExcluded({ id: 3 } as User, body)).toBe(answer);
expect(service.setExcluded).toHaveBeenCalledWith(3, body);
});
});
@@ -0,0 +1,223 @@
/**
* STA-02, the pure half of the user's activity sums: whose tours count, on
* which day, on what basis, and what is never added up twice.
*/
import type { TourStatsExecutionRow, TourStatsTrackRow } from '../../../src/db/repositories/TourActivities.repository';
import { buildActivityStats, isOwnExecution } from '../../../src/nest/tours/tour-activity-stats.helpers';
import { describe, expect, it } from 'vitest';
const ME = 1;
const FRIEND = 2;
function row(overrides: Partial<TourStatsExecutionRow> = {}): TourStatsExecutionRow {
return {
place_id: 10,
name: 'Ridge',
tour_type: 'hike',
distance: 12.5,
elevation_gain: 800,
elevation_loss: 790,
derived_from_place_id: null,
trip_id: 5,
trip_title: 'Alps',
trip_owner_id: ME,
assignment_id: 100,
date: '2026-07-02',
participant_count: 0,
joined: 0,
...overrides,
};
}
const LINE = JSON.stringify([
[47, 11, 600],
[47.01, 11, 700],
[47.02, 11, 650],
]);
function track(overrides: Partial<TourStatsTrackRow> = {}): TourStatsTrackRow {
return {
place_id: 10,
track_place_id: 20,
assignment_id: 100,
track_name: 'Watch export',
track_route_geometry: LINE,
track_recorded_route_geometry: null,
...overrides,
};
}
function stats(
executions: TourStatsExecutionRow[],
tracks: TourStatsTrackRow[] = [],
excluded: number[] = [],
userId = ME,
) {
return buildActivityStats({ userId, executions, tracks, excluded: new Set(excluded) });
}
describe('isOwnExecution (STA-02)', () => {
it('STA2-HELP-001: a day naming the user is theirs, a day naming nobody is the owner only', () => {
expect(isOwnExecution(row({ joined: 1, participant_count: 2, trip_owner_id: FRIEND }), ME)).toBe(true);
expect(isOwnExecution(row({ participant_count: 0 }), ME)).toBe(true);
expect(isOwnExecution(row({ participant_count: 0, trip_owner_id: FRIEND }), ME)).toBe(false);
expect(isOwnExecution(row({ participant_count: 1, joined: 0 }), ME)).toBe(false);
});
});
describe('buildActivityStats (STA-02)', () => {
it('STA2-HELP-002: a planned tour on a dated day of the own trip counts with its plan', () => {
expect(stats([row()])).toEqual({
entries: [
{
place_id: 10,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12.5,
ascent_m: 800,
descent_m: 790,
excluded: false,
},
],
uncounted: { unassigned: 0, undated: 0 },
});
});
it('STA2-HELP-003: a tour on several days counts once, on its first day, saying on how many', () => {
const { entries } = stats([
row({ assignment_id: 101, date: '2026-07-04' }),
row({ assignment_id: 100, date: '2026-07-02' }),
row({ assignment_id: 102, date: null }),
]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ date: '2026-07-02', day_count: 2 });
});
it('STA2-HELP-004: a linked recording replaces the plan and is no tour of its own', () => {
const { entries } = stats(
[row(), row({ place_id: 20, name: 'Watch export', assignment_id: null, date: null })],
[track()],
);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
place_id: 10,
basis: 'track',
track: { place_id: 20, name: 'Watch export' },
date: '2026-07-02',
day_count: 1,
ascent_m: 100,
descent_m: 50,
});
expect(entries[0]?.distance_km).toBeCloseTo(2.22, 1);
});
it('STA2-HELP-005: a recording on no day counts on the first day; one recorded per day counts per day', () => {
const executions = [row({ assignment_id: 100 }), row({ assignment_id: 101, date: '2026-07-03' })];
const noDay = stats(executions, [track({ assignment_id: null })]);
expect(noDay.entries.map((entry) => entry.date)).toEqual(['2026-07-02']);
const twice = stats(executions, [track(), track({ track_place_id: 21, assignment_id: 101 })]);
expect(twice.entries.map((entry) => [entry.date, entry.track?.place_id])).toEqual([
['2026-07-03', 21],
['2026-07-02', 20],
]);
});
it('STA2-HELP-006: a recording is measured on the line it kept when it was planned over since', () => {
const kept = JSON.stringify([
[47, 11],
[47.1, 11],
]);
const { entries } = stats([row()], [track({ track_recorded_route_geometry: kept })]);
expect(entries[0]?.distance_km).toBeCloseTo(11.1, 0);
expect(entries[0]).toMatchObject({ ascent_m: null, descent_m: null });
});
it('STA2-HELP-007: a recording without a line knows no distance', () => {
const { entries } = stats([row()], [track({ track_route_geometry: null })]);
expect(entries[0]).toMatchObject({ basis: 'track', distance_km: null, ascent_m: null });
});
it('STA2-HELP-008: the recording of somebody else’s day is theirs, so the plan stands for the user', () => {
const executions = [
row({ assignment_id: 100, participant_count: 0 }),
row({ assignment_id: 101, date: '2026-07-03', participant_count: 1, joined: 0 }),
];
const { entries } = stats(executions, [track({ assignment_id: 101 })]);
expect(entries).toEqual([expect.objectContaining({ basis: 'plan', date: '2026-07-02' })]);
});
it('STA2-HELP-009: access to somebody else’s trip counts nothing; a day naming the user counts', () => {
const foreign = { trip_owner_id: FRIEND, trip_id: 6, trip_title: 'Theirs' };
expect(stats([row(foreign)]).entries).toEqual([]);
expect(stats([row({ ...foreign, participant_count: 2, joined: 1 })]).entries).toHaveLength(1);
expect(stats([row(foreign)]).uncounted).toEqual({ unassigned: 0, undated: 0 });
});
it('STA2-HELP-010: a day naming only others leaves the owner out, and says nothing about it', () => {
expect(stats([row({ participant_count: 1, joined: 0 })])).toEqual({
entries: [],
uncounted: { unassigned: 0, undated: 0 },
});
});
it('STA2-HELP-011: own tours on no day or only on undated days are named as not counting', () => {
const result = stats([
row({ place_id: 11, assignment_id: null, date: null }),
row({ place_id: 12, assignment_id: 103, date: null }),
row({ place_id: 13, assignment_id: 104, date: 'not a date' }),
]);
expect(result).toEqual({ entries: [], uncounted: { unassigned: 1, undated: 2 } });
});
it('STA2-HELP-012: a whole route does not count beside the stages planned from it', () => {
const route = row({ place_id: 30, name: 'Whole route', distance: 60, assignment_id: 300, date: '2026-07-01' });
const stage1 = row({ place_id: 31, name: 'Stage 1', distance: 20, derived_from_place_id: 30, assignment_id: 301 });
const stage2 = row({
place_id: 32,
name: 'Stage 2',
distance: 40,
derived_from_place_id: 30,
assignment_id: 302,
date: '2026-07-03',
});
expect(stats([route, stage1, stage2]).entries.map((entry) => entry.place_id)).toEqual([32, 31]);
// With no stage on a day, the route is what was done.
const unplanned = row({ ...stage1, assignment_id: null, date: null });
expect(stats([route, unplanned]).entries.map((entry) => entry.place_id)).toEqual([30]);
});
it('STA2-HELP-013: a paddle tour carries no heights, planned or recorded', () => {
const paddle = row({ tour_type: 'kayak', elevation_gain: 12, elevation_loss: 12 });
expect(stats([paddle]).entries[0]).toMatchObject({ ascent_m: null, descent_m: null, distance_km: 12.5 });
expect(stats([paddle], [track()]).entries[0]).toMatchObject({ ascent_m: null, descent_m: null });
});
it('STA2-HELP-014: an unknown or broken measure stays unknown, never zero or negative', () => {
const { entries } = stats([row({ distance: null, elevation_gain: -5, elevation_loss: Number.NaN })]);
expect(entries[0]).toMatchObject({ distance_km: null, ascent_m: null, descent_m: null });
});
it('STA2-HELP-015: an excluded tour stays listed, flagged, with every entry of it', () => {
const { entries } = stats([row()], [track()], [10]);
expect(entries).toEqual([expect.objectContaining({ place_id: 10, excluded: true, basis: 'track' })]);
});
it('STA2-HELP-016: the newest first, then by name; a timestamp on a day reads as its calendar day', () => {
const { entries } = stats([
row({ place_id: 1, name: 'Zugspitze', assignment_id: 1, date: '2026-07-02 00:00:00' }),
row({ place_id: 2, name: 'Alpspitze', assignment_id: 2, date: '2026-07-02' }),
row({ place_id: 3, name: 'Hochkalter', assignment_id: 3, date: '2026-08-01' }),
]);
expect(entries.map((entry) => [entry.name, entry.date])).toEqual([
['Hochkalter', '2026-08-01'],
['Alpspitze', '2026-07-02'],
['Zugspitze', '2026-07-02'],
]);
});
});
@@ -0,0 +1,121 @@
/**
* TourActivityStatsMcp (STA-02): the user's own tour activities and leaving
* one out, over MCP, through the same service as the Atlas: the sums of a
* period and a type, the tours they are made of and what they rest on. Today
* is pinned, so a tour's day is over or not whenever the suite runs.
*/
import { TourActivityExclusions } from '../../../src/db/entities/TourActivityExclusions.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { TourActivityStatsMcp } from '../../../src/nest/tours/tour-activity-stats.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
import { countRows } from '../../helpers/factories/rows';
import { makeTour } from '../../helpers/factories/tours';
import { makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let mcp: TourActivityStatsMcp;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(TourActivityStatsMcp);
});
beforeEach(() => {
resetTestDb(db);
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-07-10T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());
afterAll(async () => {
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
const text = result.content[0]?.text ?? '';
return result.isError ? { error: text } : JSON.parse(text);
}
/** A hike and a paddle tour done in July, a hike still ahead, and a trip somebody else owns. */
async function fixture() {
const { user } = await makeUser(orm());
const { user: stranger } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const tours: Record<string, number> = {};
for (const [name, type, distance, date] of [
['Ridge', 'hike', 12, '2026-07-02'],
['Lake', 'kayak', 9, '2026-07-04'],
['Summit', 'hike', 15, '2026-07-20'],
] as const) {
const day = await makeDay(orm(), trip.id, { date });
const place = await makePlace(orm(), trip.id, { name });
await makeTour(orm(), place.id, { tourTypeRef: type, distance, elevation_gain: 500, elevation_loss: 500 });
await makeDayAssignment(orm(), day.id, place.id);
tours[name] = place.id;
}
const closed = await makeTrip(orm(), stranger.id);
const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' });
await makeTour(orm(), hidden.id);
return { user, tours, hidden };
}
describe('TourActivityStatsMcp', () => {
it('STA2-MCP-001: get_tour_activity_stats sums the done tours, by type, and says what is still ahead', async () => {
const { user, tours } = await fixture();
const result = read(await mcp.getTourActivityStats({}, ctx(user.id)));
expect(result.today).toBe('2026-07-10');
expect(result.totals).toEqual({
count: 2,
distanceKm: { value: 21, known: 2, of: 2 },
ascentM: { value: 500, known: 1, of: 1 },
descentM: { value: 500, known: 1, of: 1 },
byBasis: { plan: 2, track: 0 },
});
expect(result.by_type.map((type: { type: string }) => type.type)).toEqual(['hike', 'kayak']);
expect(result.tours.map((tour: { place_id: number; basis: string }) => [tour.place_id, tour.basis])).toEqual([
[tours.Lake, 'plan'],
[tours.Ridge, 'plan'],
]);
expect(result.upcoming).toBe(1);
expect(result.uncounted).toEqual({ unassigned: 0, undated: 0 });
});
it('STA2-MCP-002: narrows by period and type', async () => {
const { user, tours } = await fixture();
const paddled = read(await mcp.getTourActivityStats({ type: 'kayak' }, ctx(user.id)));
expect(paddled.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([tours.Lake]);
const early = read(await mcp.getTourActivityStats({ from: '2026-07-01', to: '2026-07-03' }, ctx(user.id)));
expect(early.totals.count).toBe(1);
expect(early.upcoming).toBe(0);
});
it('STA2-MCP-003: set_tour_activity_excluded leaves a tour out and counts it again', async () => {
const { user, tours } = await fixture();
const left = read(await mcp.setTourActivityExcluded({ place_id: tours.Ridge, excluded: true }, ctx(user.id)));
expect(left.totals.count).toBe(1);
expect(left.tours.find((tour: { place_id: number }) => tour.place_id === tours.Ridge)?.excluded).toBe(true);
const back = read(await mcp.setTourActivityExcluded({ place_id: tours.Ridge, excluded: false }, ctx(user.id)));
expect(back.totals.count).toBe(2);
expect(await countRows(orm(), TourActivityExclusions)).toBe(0);
});
it('STA2-MCP-004: a tour of a trip the user cannot open is not found, as over REST', async () => {
const { user, hidden } = await fixture();
expect(read(await mcp.setTourActivityExcluded({ place_id: hidden.id, excluded: true }, ctx(user.id)))).toEqual({
error: 'Tour not found.',
});
expect(await countRows(orm(), TourActivityExclusions)).toBe(0);
});
});
@@ -0,0 +1,152 @@
/**
* TourActivityStatsService (STA-02): the user's own tour activities across
* every trip they can open, and leaving one out of them. The service comes out
* of ToursModule over a snapshot test db; rows are seeded through the ORM
* factories.
*/
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
import { TourActivityExclusions } from '../../../src/db/entities/TourActivityExclusions.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { TourActivityStatsService } from '../../../src/nest/tours/tour-activity-stats.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makeDayAssignment } from '../../helpers/factories/itinerary';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
import { makeTour, makeTourActivity } from '../../helpers/factories/tours';
import { addTripMember, makeDay, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { NotFoundException } from '@nestjs/common';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let service: TourActivityStatsService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
service = t.get(TourActivityStatsService);
});
beforeEach(() => resetTestDb(db));
afterAll(async () => {
await t.close();
db.close();
});
const RECORDED = JSON.stringify([
[47, 11, 600],
[47.01, 11.005, 760],
[47.02, 11, 880],
]);
/**
* The user's trip with a hike on a day and a paddle tour recorded on another;
* a trip of a friend the user is a member of, with one tour the friend did
* alone and one the user is named on; and a trip the user cannot open.
*/
async function fixture() {
const { user } = await makeUser(orm());
const { user: friend } = await makeUser(orm());
const { user: stranger } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const day1 = await makeDay(orm(), trip.id, { date: '2026-07-02' });
const day2 = await makeDay(orm(), trip.id, { date: '2026-07-03' });
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
await makeTour(orm(), hike.id, { distance: 12.5, elevation_gain: 800, elevation_loss: 790 });
await makeDayAssignment(orm(), day1.id, hike.id);
const paddle = await makePlace(orm(), trip.id, { name: 'Lake' });
await makeTour(orm(), paddle.id, { tourTypeRef: 'kayak', distance: 9 });
const onDay2 = await makeDayAssignment(orm(), day2.id, paddle.id);
const watch = await makePlace(orm(), trip.id, { name: 'Watch export', route_geometry: RECORDED });
await makeTour(orm(), watch.id, { origin: 'gpx' });
await makeTourActivity(orm(), paddle.id, watch.id, { assignment: onDay2.id });
const idle = await makePlace(orm(), trip.id, { name: 'Idle' });
await makeTour(orm(), idle.id);
const theirs = await makeTrip(orm(), friend.id, { title: 'Theirs' });
await addTripMember(orm(), theirs.id, user.id);
const theirDay = await makeDay(orm(), theirs.id, { date: '2026-08-10' });
const alone = await makePlace(orm(), theirs.id, { name: 'Alone' });
await makeTour(orm(), alone.id, { distance: 30 });
await makeDayAssignment(orm(), theirDay.id, alone.id);
const together = await makePlace(orm(), theirs.id, { name: 'Together' });
await makeTour(orm(), together.id, { distance: 7 });
const togetherOn = await makeDayAssignment(orm(), theirDay.id, together.id);
await insertRow(orm(), AssignmentParticipants, { assignment: togetherOn.id, user: user.id });
const closed = await makeTrip(orm(), stranger.id);
const closedDay = await makeDay(orm(), closed.id, { date: '2026-07-01' });
const hidden = await makePlace(orm(), closed.id, { name: 'Hidden' });
await makeTour(orm(), hidden.id);
await makeDayAssignment(orm(), closedDay.id, hidden.id);
return { user, friend, hike, paddle, watch, alone, together, togetherOn, hidden };
}
describe('TourActivityStatsService', () => {
it('STA2-SVC-001: lists the own tours of every trip the user can open, plan or recording, newest first', async () => {
const { user, hike, paddle, watch, together } = await fixture();
const result = await service.stats(user.id);
expect(result.entries.map((entry) => [entry.place_id, entry.basis, entry.date])).toEqual([
[together.id, 'plan', '2026-08-10'],
[paddle.id, 'track', '2026-07-03'],
[hike.id, 'plan', '2026-07-02'],
]);
expect(result.entries[1]).toMatchObject({
tour_type: 'kayak',
trip: { title: 'Alps' },
track: { place_id: watch.id, name: 'Watch export' },
ascent_m: null,
descent_m: null,
});
expect(result.entries[1]?.distance_km).toBeCloseTo(2.35, 1);
expect(result.uncounted).toEqual({ unassigned: 1, undated: 0 });
});
it('STA2-SVC-002: the friend sees the tour they did alone, and not the user’s trip they cannot open', async () => {
const { friend, alone, together } = await fixture();
const result = await service.stats(friend.id);
// Together names only the user, so it is not the friend's, owner or not.
expect(result.entries.map((entry) => entry.place_id)).toEqual([alone.id]);
expect(result.entries.some((entry) => entry.place_id === together.id)).toBe(false);
});
it('STA2-SVC-003: leaving a tour out keeps it listed, flagged; counting it again clears the flag', async () => {
const { user, hike } = await fixture();
const left = await service.setExcluded(user.id, { place_id: hike.id, excluded: true });
expect(left.entries.find((entry) => entry.place_id === hike.id)?.excluded).toBe(true);
expect(await findRow(orm(), Tours, { place: hike.id })).toMatchObject({ distance: 12.5 });
const back = await service.setExcluded(user.id, { place_id: hike.id, excluded: false });
expect(back.entries.find((entry) => entry.place_id === hike.id)?.excluded).toBe(false);
expect(await countRows(orm(), TourActivityExclusions, {})).toBe(0);
});
it('STA2-SVC-004: a tour of a trip the user cannot open reads as missing and nothing is written', async () => {
const { user, hidden } = await fixture();
await expect(service.setExcluded(user.id, { place_id: hidden.id, excluded: true })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.setExcluded(user.id, { place_id: 999_999, excluded: true })).rejects.toBeInstanceOf(
NotFoundException,
);
expect(await countRows(orm(), TourActivityExclusions, {})).toBe(0);
});
it('STA2-SVC-005: one user leaving a tour out changes nobody else’s list', async () => {
const { user, friend, together, togetherOn } = await fixture();
await insertRow(orm(), AssignmentParticipants, { assignment: togetherOn.id, user: friend.id });
await service.setExcluded(user.id, { place_id: together.id, excluded: true });
const friends = await service.stats(friend.id);
expect(friends.entries.find((entry) => entry.place_id === together.id)?.excluded).toBe(false);
});
it('STA2-SVC-006: a user without trips has nothing to count', async () => {
const { user } = await makeUser(orm());
expect(await service.stats(user.id)).toEqual({ entries: [], uncounted: { unassigned: 0, undated: 0 } });
});
});
+32
View File
@@ -1260,5 +1260,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'قارن على الخريطة',
'tours.activity.compare.showHint': 'يرسم المسار الفعلي منقطًا بجانب المخطط. لا يتغير شيء.',
'tours.activity.compare.hide': 'إنهاء المقارنة',
'tours.stats.title': 'جولاتي المنجزة',
'tours.stats.sub': 'بناءً على تخطيط رحلتك؛ وتحل المسارات الفعلية المرتبطة محله',
'tours.stats.basisInfo':
'تُحتسب الجولة بعد انتهاء يومها، بقيم تخطيطك. ويحل المسار الفعلي المرتبط محلها. لا تُحتسب إلا الأيام التي تشارك فيها.',
'tours.stats.basisPlan': 'مخطط',
'tours.stats.basisPlanHint': 'من تخطيط الرحلة، غير مقيس',
'tours.stats.basisTrack': 'مسجّل',
'tours.stats.basisTrackHint': 'من المسار الفعلي المرتبط {name}',
'tours.stats.basisTrackCountHint': 'تحل المسارات الفعلية المرتبطة محل القيم المخططة',
'tours.stats.period': 'الفترة',
'tours.stats.period.all': 'كل الأوقات',
'tours.stats.period.thisYear': 'هذا العام',
'tours.stats.period.last12': 'آخر 12 شهرًا',
'tours.stats.allTypes': 'كل الأنواع',
'tours.stats.byType': 'حسب النوع',
'tours.stats.showTours': 'عرض الجولات',
'tours.stats.none': 'لا جولات منجزة في هذه الفترة',
'tours.stats.empty': 'لا جولات منجزة بعد',
'tours.stats.upcoming': 'لم تأتِ بعد',
'tours.stats.upcomingHint': 'تُحتسب الجولة بعد انتهاء يومها',
'tours.stats.unassigned': 'بلا يوم',
'tours.stats.unassignedHint': 'جولات رحلاتك التي ليست في أي يوم لا تُحتسب',
'tours.stats.undated': 'بلا تاريخ',
'tours.stats.undatedHint': 'الجولات في أيام بلا تاريخ لا تُحتسب',
'tours.stats.multiDayHint': 'مخططة على عدة أيام، تُحتسب مرة واحدة',
'tours.stats.exclude': 'عدم الاحتساب',
'tours.stats.excludeHint': 'لجولة أُلغيت. تبقى في الخطة وتتوقف فقط عن الاحتساب هنا',
'tours.stats.include': 'احتسابها مجددًا',
'tours.stats.excluded': 'غير محتسبة',
'tours.stats.excludeFailed': 'تعذر تغيير الجولة',
'tours.stats.unavailable': 'تعذر تحميل الجولات المنجزة',
'tours.stats.openTrip': 'فتح الرحلة',
};
export default tours;
+32
View File
@@ -1228,5 +1228,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Xəritədə müqayisə et',
'tours.activity.compare.showHint': 'Faktiki izi planın yanında nöqtəli çəkir. Heç nə dəyişmir.',
'tours.activity.compare.hide': 'Müqayisəni bitir',
'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.',
'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',
'tours.stats.basisTrackHint': 'Əlaqələndirilmiş faktiki izdən: {name}',
'tours.stats.basisTrackCountHint': 'Əlaqələndirilmiş faktiki izlər planlaşdırılmış dəyərləri əvəz edir',
'tours.stats.period': 'Dövr',
'tours.stats.period.all': 'Bütün dövr',
'tours.stats.period.thisYear': 'Bu il',
'tours.stats.period.last12': 'Son 12 ay',
'tours.stats.allTypes': 'Bütün növlər',
'tours.stats.byType': 'Növə görə',
'tours.stats.showTours': 'Turları göstər',
'tours.stats.none': 'Bu dövrdə tamamlanmış tur yoxdur',
'tours.stats.empty': 'Hələ tamamlanmış tur yoxdur',
'tours.stats.upcoming': 'Hələ qarşıda',
'tours.stats.upcomingHint': 'Tur günü bitəndə sayılır',
'tours.stats.unassigned': 'Günsüz',
'tours.stats.unassignedHint': 'Səyahətlərinin heç bir gününə qoyulmamış turlar sayılmır',
'tours.stats.undated': 'Tarixsiz',
'tours.stats.undatedHint': 'Tarixi olmayan günlərdəki turlar sayılmır',
'tours.stats.multiDayHint': 'Bir neçə günə planlaşdırılıb, bir dəfə sayılıb',
'tours.stats.exclude': 'Sayma',
'tours.stats.excludeHint': 'Baş tutmayan tur üçün. Planda qalır, sadəcə burada sayılmır',
'tours.stats.include': 'Yenidən say',
'tours.stats.excluded': 'Sayılmayıb',
'tours.stats.excludeFailed': 'Turu dəyişmək mümkün olmadı',
'tours.stats.unavailable': 'Tamamlanmış turları yükləmək mümkün olmadı',
'tours.stats.openTrip': 'Səyahəti aç',
};
export default tours;
+32
View File
@@ -1240,5 +1240,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Comparar no mapa',
'tours.activity.compare.showHint': 'Desenha a trilha real pontilhada ao lado do plano. Nada muda.',
'tours.activity.compare.hide': 'Encerrar comparação',
'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.',
'tours.stats.basisPlan': 'Planejado',
'tours.stats.basisPlanHint': 'Do planejamento da viagem, não medido',
'tours.stats.basisTrack': 'Gravado',
'tours.stats.basisTrackHint': 'Da trilha real vinculada {name}',
'tours.stats.basisTrackCountHint': 'Trilhas reais vinculadas substituem os valores planejados',
'tours.stats.period': 'Período',
'tours.stats.period.all': 'Desde sempre',
'tours.stats.period.thisYear': 'Este ano',
'tours.stats.period.last12': 'Últimos 12 meses',
'tours.stats.allTypes': 'Todos os tipos',
'tours.stats.byType': 'Por tipo',
'tours.stats.showTours': 'Ver passeios',
'tours.stats.none': 'Nenhum passeio concluído neste período',
'tours.stats.empty': 'Ainda nenhum passeio concluído',
'tours.stats.upcoming': 'Ainda por vir',
'tours.stats.upcomingHint': 'Um passeio conta quando o dia dele termina',
'tours.stats.unassigned': 'Sem dia',
'tours.stats.unassignedHint': 'Passeios das suas viagens que não estão em nenhum dia não contam',
'tours.stats.undated': 'Sem data',
'tours.stats.undatedHint': 'Passeios em dias sem data não contam',
'tours.stats.multiDayHint': 'Planejado em vários dias, contado uma vez',
'tours.stats.exclude': 'Não contar',
'tours.stats.excludeHint': 'Para um passeio que não aconteceu. Ele continua no plano e só deixa de contar aqui',
'tours.stats.include': 'Contar de novo',
'tours.stats.excluded': 'Não contado',
'tours.stats.excludeFailed': 'Não foi possível alterar o passeio',
'tours.stats.unavailable': 'Não foi possível carregar os passeios realizados',
'tours.stats.openTrip': 'Abrir viagem',
};
export default tours;
+32
View File
@@ -1247,5 +1247,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Compara al mapa',
'tours.activity.compare.showHint': 'Dibuixa el traç real puntejat al costat del pla. No canvia res.',
'tours.activity.compare.hide': 'Acaba la comparació',
'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.',
'tours.stats.basisPlan': 'Planificat',
'tours.stats.basisPlanHint': 'De la planificació del viatge, no mesurat',
'tours.stats.basisTrack': 'Enregistrat',
'tours.stats.basisTrackHint': 'Del traç real vinculat {name}',
'tours.stats.basisTrackCountHint': 'Els traços reals vinculats substitueixen els valors planificats',
'tours.stats.period': 'Període',
'tours.stats.period.all': 'Des de sempre',
'tours.stats.period.thisYear': 'Aquest any',
'tours.stats.period.last12': 'Últims 12 mesos',
'tours.stats.allTypes': 'Tots els tipus',
'tours.stats.byType': 'Per tipus',
'tours.stats.showTours': 'Mostra les excursions',
'tours.stats.none': 'Cap excursió acabada en aquest període',
'tours.stats.empty': 'Encara cap excursió acabada',
'tours.stats.upcoming': 'Per fer',
'tours.stats.upcomingHint': 'Una excursió compta quan el seu dia s’ha acabat',
'tours.stats.unassigned': 'Sense dia',
'tours.stats.unassignedHint': 'Les excursions dels teus viatges que no són en cap dia no compten',
'tours.stats.undated': 'Sense data',
'tours.stats.undatedHint': 'Les excursions en dies sense data no compten',
'tours.stats.multiDayHint': 'Planificada en diversos dies, comptada un cop',
'tours.stats.exclude': 'No comptar',
'tours.stats.excludeHint': 'Per a una excursió que no es va fer. Es queda al pla i només deixa de comptar aquí',
'tours.stats.include': 'Torna a comptar',
'tours.stats.excluded': 'No comptada',
'tours.stats.excludeFailed': 'No s’ha pogut canviar l’excursió',
'tours.stats.unavailable': 'No s’han pogut carregar les excursions fetes',
'tours.stats.openTrip': 'Obre el viatge',
};
export default tours;
+32
View File
@@ -1226,5 +1226,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Porovnat na mapě',
'tours.activity.compare.showHint': 'Vykreslí skutečnou trasu tečkovaně vedle plánu. Nic se nemění.',
'tours.activity.compare.hide': 'Ukončit porovnání',
'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íš.',
'tours.stats.basisPlan': 'Plánováno',
'tours.stats.basisPlanHint': 'Z plánu cesty, neměřeno',
'tours.stats.basisTrack': 'Zaznamenáno',
'tours.stats.basisTrackHint': 'Z propojené skutečné trasy {name}',
'tours.stats.basisTrackCountHint': 'Propojené skutečné trasy nahrazují plánované hodnoty',
'tours.stats.period': 'Období',
'tours.stats.period.all': 'Celkem',
'tours.stats.period.thisYear': 'Letos',
'tours.stats.period.last12': 'Posledních 12 měsíců',
'tours.stats.allTypes': 'Všechny typy',
'tours.stats.byType': 'Podle druhu',
'tours.stats.showTours': 'Zobrazit výlety',
'tours.stats.none': 'V tomto období žádné absolvované výlety',
'tours.stats.empty': 'Zatím žádné absolvované výlety',
'tours.stats.upcoming': 'Teprve čeká',
'tours.stats.upcomingHint': 'Výlet se započítá, jakmile jeho den skončí',
'tours.stats.unassigned': 'Bez dne',
'tours.stats.unassignedHint': 'Výlety tvých cest, které nejsou v žádném dni, se nepočítají',
'tours.stats.undated': 'Bez data',
'tours.stats.undatedHint': 'Výlety ve dnech bez data se nepočítají',
'tours.stats.multiDayHint': 'Naplánováno na více dní, započteno jednou',
'tours.stats.exclude': 'Nezapočítávat',
'tours.stats.excludeHint': 'Pro výlet, který se nekonal. Zůstane v plánu a jen se tu přestane počítat',
'tours.stats.include': 'Znovu započítat',
'tours.stats.excluded': 'Nezapočteno',
'tours.stats.excludeFailed': 'Výlet se nepodařilo změnit',
'tours.stats.unavailable': 'Absolvované výlety se nepodařilo načíst',
'tours.stats.openTrip': 'Otevřít cestu',
};
export default tours;
+32
View File
@@ -1254,6 +1254,38 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Auf der Karte vergleichen',
'tours.activity.compare.showHint': 'Zeichnet den Ist-Track gepunktet neben die Planung. Es ändert sich nichts.',
'tours.activity.compare.hide': 'Vergleich beenden',
'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.',
'tours.stats.basisPlan': 'Geplant',
'tours.stats.basisPlanHint': 'Aus deiner Reiseplanung, nicht gemessen',
'tours.stats.basisTrack': 'Aufgezeichnet',
'tours.stats.basisTrackHint': 'Aus dem verknüpften Ist-Track {name}',
'tours.stats.basisTrackCountHint': 'Verknüpfte Ist-Tracks ersetzen die geplanten Werte',
'tours.stats.period': 'Zeitraum',
'tours.stats.period.all': 'Gesamt',
'tours.stats.period.thisYear': 'Dieses Jahr',
'tours.stats.period.last12': 'Letzte 12 Monate',
'tours.stats.allTypes': 'Alle Arten',
'tours.stats.byType': 'Nach Art',
'tours.stats.showTours': 'Touren anzeigen',
'tours.stats.none': 'Keine abgeschlossenen Touren in diesem Zeitraum',
'tours.stats.empty': 'Noch keine abgeschlossenen Touren',
'tours.stats.upcoming': 'Noch offen',
'tours.stats.upcomingHint': 'Eine Tour zählt, sobald ihr Tag vorbei ist',
'tours.stats.unassigned': 'Ohne Tag',
'tours.stats.unassignedHint': 'Touren deiner Reisen, die auf keinem Tag liegen, zählen nicht',
'tours.stats.undated': 'Ohne Datum',
'tours.stats.undatedHint': 'Touren auf Tagen ohne Datum zählen nicht',
'tours.stats.multiDayHint': 'An mehreren Tagen geplant, einmal gezählt',
'tours.stats.exclude': 'Nicht mitzählen',
'tours.stats.excludeHint': 'Für eine ausgefallene Tour. Sie bleibt im Plan und zählt nur hier nicht mehr',
'tours.stats.include': 'Wieder mitzählen',
'tours.stats.excluded': 'Nicht gezählt',
'tours.stats.excludeFailed': 'Die Tour konnte nicht geändert werden',
'tours.stats.unavailable': 'Touraktivitäten konnten nicht geladen werden',
'tours.stats.openTrip': 'Reise öffnen',
};
export default tours;
+32
View File
@@ -1215,5 +1215,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Compare on map',
'tours.activity.compare.showHint': 'Draws the actual track dotted beside the plan. Nothing changes.',
'tours.activity.compare.hide': 'End comparison',
'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.',
'tours.stats.basisPlan': 'Planned',
'tours.stats.basisPlanHint': 'From your trip planning, not measured',
'tours.stats.basisTrack': 'Recorded',
'tours.stats.basisTrackHint': 'From the linked actual track {name}',
'tours.stats.basisTrackCountHint': 'Linked actual tracks replace the planned values',
'tours.stats.period': 'Period',
'tours.stats.period.all': 'All time',
'tours.stats.period.thisYear': 'This year',
'tours.stats.period.last12': 'Last 12 months',
'tours.stats.allTypes': 'All types',
'tours.stats.byType': 'By type',
'tours.stats.showTours': 'Show tours',
'tours.stats.none': 'No completed tours in this period',
'tours.stats.empty': 'No completed tours yet',
'tours.stats.upcoming': 'Still ahead',
'tours.stats.upcomingHint': 'A tour counts once its day is over',
'tours.stats.unassigned': 'On no day',
'tours.stats.unassignedHint': 'Tours of your trips that are on no day do not count',
'tours.stats.undated': 'No date',
'tours.stats.undatedHint': 'Tours on days without a date do not count',
'tours.stats.multiDayHint': 'Planned on several days, counted once',
'tours.stats.exclude': 'Leave out',
'tours.stats.excludeHint': 'For a tour that fell through. It stays in the plan and only stops counting here',
'tours.stats.include': 'Count again',
'tours.stats.excluded': 'Left out',
'tours.stats.excludeFailed': 'The tour could not be changed',
'tours.stats.unavailable': 'Tour activities could not be loaded',
'tours.stats.openTrip': 'Open trip',
};
export default tours;
+32
View File
@@ -1240,5 +1240,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Comparar en el mapa',
'tours.activity.compare.showHint': 'Dibuja el track real punteado junto al plan. No cambia nada.',
'tours.activity.compare.hide': 'Terminar la comparación',
'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.',
'tours.stats.basisPlan': 'Planificado',
'tours.stats.basisPlanHint': 'Según la planificación del viaje, no medido',
'tours.stats.basisTrack': 'Grabado',
'tours.stats.basisTrackHint': 'Del track real vinculado {name}',
'tours.stats.basisTrackCountHint': 'Los tracks reales vinculados sustituyen los valores planificados',
'tours.stats.period': 'Periodo',
'tours.stats.period.all': 'Desde siempre',
'tours.stats.period.thisYear': 'Este año',
'tours.stats.period.last12': 'Últimos 12 meses',
'tours.stats.allTypes': 'Todos los tipos',
'tours.stats.byType': 'Por tipo',
'tours.stats.showTours': 'Ver excursiones',
'tours.stats.none': 'No hay excursiones terminadas en este periodo',
'tours.stats.empty': 'Todavía no hay excursiones terminadas',
'tours.stats.upcoming': 'Pendientes',
'tours.stats.upcomingHint': 'Una excursión cuenta cuando su día ha terminado',
'tours.stats.unassigned': 'Sin día',
'tours.stats.unassignedHint': 'Las excursiones de tus viajes que no están en ningún día no cuentan',
'tours.stats.undated': 'Sin fecha',
'tours.stats.undatedHint': 'Las excursiones en días sin fecha no cuentan',
'tours.stats.multiDayHint': 'Planificada en varios días, contada una vez',
'tours.stats.exclude': 'No contar',
'tours.stats.excludeHint': 'Para una excursión que se canceló. Sigue en el plan y solo deja de contar aquí',
'tours.stats.include': 'Volver a contar',
'tours.stats.excluded': 'No contada',
'tours.stats.excludeFailed': 'No se pudo cambiar la excursión',
'tours.stats.unavailable': 'No se pudieron cargar las excursiones realizadas',
'tours.stats.openTrip': 'Abrir viaje',
};
export default tours;
+32
View File
@@ -1224,5 +1224,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Võrdle kaardil',
'tours.activity.compare.showHint': 'Joonistab tegeliku raja plaani kõrvale punktiirina. Midagi ei muutu.',
'tours.activity.compare.hide': 'Lõpeta võrdlus',
'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.',
'tours.stats.basisPlan': 'Planeeritud',
'tours.stats.basisPlanHint': 'Reisi planeerimisest, mõõtmata',
'tours.stats.basisTrack': 'Salvestatud',
'tours.stats.basisTrackHint': 'Lingitud tegelikult rajalt {name}',
'tours.stats.basisTrackCountHint': 'Lingitud tegelikud rajad asendavad planeeritud väärtused',
'tours.stats.period': 'Ajavahemik',
'tours.stats.period.all': 'Kogu aeg',
'tours.stats.period.thisYear': 'Sel aastal',
'tours.stats.period.last12': 'Viimased 12 kuud',
'tours.stats.allTypes': 'Kõik tüübid',
'tours.stats.byType': 'Liigi järgi',
'tours.stats.showTours': 'Näita retki',
'tours.stats.none': 'Selles ajavahemikus pole tehtud retki',
'tours.stats.empty': 'Tehtud retki veel pole',
'tours.stats.upcoming': 'Veel ees',
'tours.stats.upcomingHint': 'Retk läheb arvesse, kui selle päev on läbi',
'tours.stats.unassigned': 'Päevata',
'tours.stats.unassignedHint': 'Sinu reiside retked, mis pole üheski päevas, ei lähe arvesse',
'tours.stats.undated': 'Kuupäevata',
'tours.stats.undatedHint': 'Kuupäevata päevade retked ei lähe arvesse',
'tours.stats.multiDayHint': 'Planeeritud mitmele päevale, arvestatud üks kord',
'tours.stats.exclude': 'Jäta välja',
'tours.stats.excludeHint': 'Ära jäänud retke jaoks. See jääb plaani ja lakkab ainult siin arvesse minemast',
'tours.stats.include': 'Arvesta uuesti',
'tours.stats.excluded': 'Välja jäetud',
'tours.stats.excludeFailed': 'Retke ei saanud muuta',
'tours.stats.unavailable': 'Tehtud retki ei saanud laadida',
'tours.stats.openTrip': 'Ava reis',
};
export default tours;
+32
View File
@@ -1264,5 +1264,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Comparer sur la carte',
'tours.activity.compare.showHint': 'Dessine la trace réelle en pointillés à côté du plan. Rien ne change.',
'tours.activity.compare.hide': 'Terminer la comparaison',
'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.',
'tours.stats.basisPlan': 'Planifié',
'tours.stats.basisPlanHint': 'D’après la planification du voyage, non mesuré',
'tours.stats.basisTrack': 'Enregistré',
'tours.stats.basisTrackHint': 'D’après la trace réelle liée {name}',
'tours.stats.basisTrackCountHint': 'Les traces réelles liées remplacent les valeurs planifiées',
'tours.stats.period': 'Période',
'tours.stats.period.all': 'Depuis le début',
'tours.stats.period.thisYear': 'Cette année',
'tours.stats.period.last12': '12 derniers mois',
'tours.stats.allTypes': 'Tous les types',
'tours.stats.byType': 'Par type',
'tours.stats.showTours': 'Voir les sorties',
'tours.stats.none': 'Aucune sortie terminée sur cette période',
'tours.stats.empty': 'Aucune sortie terminée pour l’instant',
'tours.stats.upcoming': 'À venir',
'tours.stats.upcomingHint': 'Une sortie compte une fois sa journée terminée',
'tours.stats.unassigned': 'Sans jour',
'tours.stats.unassignedHint': 'Les sorties de tes voyages placées sur aucun jour ne comptent pas',
'tours.stats.undated': 'Sans date',
'tours.stats.undatedHint': 'Les sorties sur des jours sans date ne comptent pas',
'tours.stats.multiDayHint': 'Planifiée sur plusieurs jours, comptée une fois',
'tours.stats.exclude': 'Ne pas compter',
'tours.stats.excludeHint': 'Pour une sortie annulée. Elle reste dans le plan et cesse seulement de compter ici',
'tours.stats.include': 'Compter à nouveau',
'tours.stats.excluded': 'Non comptée',
'tours.stats.excludeFailed': 'La sortie n’a pas pu être modifiée',
'tours.stats.unavailable': 'Les sorties réalisées n’ont pas pu être chargées',
'tours.stats.openTrip': 'Ouvrir le voyage',
};
export default tours;
+32
View File
@@ -1267,5 +1267,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.showHint':
'Σχεδιάζει την πραγματική διαδρομή με τελείες δίπλα στο σχέδιο. Τίποτα δεν αλλάζει.',
'tours.activity.compare.hide': 'Τέλος σύγκρισης',
'tours.stats.title': 'Οι εκδρομές μου',
'tours.stats.sub': 'Με βάση τον σχεδιασμό του ταξιδιού· οι συνδεδεμένες πραγματικές διαδρομές τον αντικαθιστούν',
'tours.stats.basisInfo':
'Μια εκδρομή μετράει μόλις τελειώσει η μέρα της, με τις τιμές του σχεδιασμού σου. Μια συνδεδεμένη πραγματική διαδρομή τις αντικαθιστά. Μετράνε μόνο οι μέρες στις οποίες συμμετέχεις.',
'tours.stats.basisPlan': 'Σχεδιασμένο',
'tours.stats.basisPlanHint': 'Από τον σχεδιασμό του ταξιδιού, χωρίς μέτρηση',
'tours.stats.basisTrack': 'Καταγεγραμμένο',
'tours.stats.basisTrackHint': 'Από τη συνδεδεμένη πραγματική διαδρομή {name}',
'tours.stats.basisTrackCountHint': 'Οι συνδεδεμένες πραγματικές διαδρομές αντικαθιστούν τις σχεδιασμένες τιμές',
'tours.stats.period': 'Περίοδος',
'tours.stats.period.all': 'Όλη η περίοδος',
'tours.stats.period.thisYear': 'Φέτος',
'tours.stats.period.last12': 'Τελευταίοι 12 μήνες',
'tours.stats.allTypes': 'Όλοι οι τύποι',
'tours.stats.byType': 'Ανά είδος',
'tours.stats.showTours': 'Εμφάνιση εκδρομών',
'tours.stats.none': 'Καμία ολοκληρωμένη εκδρομή σε αυτή την περίοδο',
'tours.stats.empty': 'Καμία ολοκληρωμένη εκδρομή ακόμα',
'tours.stats.upcoming': 'Επερχόμενες',
'tours.stats.upcomingHint': 'Μια εκδρομή μετράει μόλις τελειώσει η μέρα της',
'tours.stats.unassigned': 'Χωρίς μέρα',
'tours.stats.unassignedHint': 'Οι εκδρομές των ταξιδιών σου που δεν είναι σε καμία μέρα δεν μετράνε',
'tours.stats.undated': 'Χωρίς ημερομηνία',
'tours.stats.undatedHint': 'Οι εκδρομές σε μέρες χωρίς ημερομηνία δεν μετράνε',
'tours.stats.multiDayHint': 'Σχεδιασμένη σε πολλές μέρες, μετριέται μία φορά',
'tours.stats.exclude': 'Να μη μετράει',
'tours.stats.excludeHint': 'Για μια εκδρομή που δεν έγινε. Μένει στο σχέδιο και απλώς σταματά να μετράει εδώ',
'tours.stats.include': 'Να μετράει ξανά',
'tours.stats.excluded': 'Δεν μετράει',
'tours.stats.excludeFailed': 'Δεν ήταν δυνατή η αλλαγή της εκδρομής',
'tours.stats.unavailable': 'Δεν ήταν δυνατή η φόρτωση των εκδρομών',
'tours.stats.openTrip': 'Άνοιγμα ταξιδιού',
};
export default tours;
+32
View File
@@ -1245,5 +1245,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Összehasonlítás a térképen',
'tours.activity.compare.showHint': 'A tényleges nyomvonalat pontozva rajzolja a terv mellé. Semmi sem változik.',
'tours.activity.compare.hide': 'Összehasonlítás befejezése',
'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.',
'tours.stats.basisPlan': 'Tervezett',
'tours.stats.basisPlanHint': 'Az utazás tervezéséből, nem mért',
'tours.stats.basisTrack': 'Rögzített',
'tours.stats.basisTrackHint': 'Az összekapcsolt tényleges nyomvonalból: {name}',
'tours.stats.basisTrackCountHint': 'Az összekapcsolt tényleges nyomvonalak felváltják a tervezett értékeket',
'tours.stats.period': 'Időszak',
'tours.stats.period.all': 'Mindenkori',
'tours.stats.period.thisYear': 'Idén',
'tours.stats.period.last12': 'Elmúlt 12 hónap',
'tours.stats.allTypes': 'Minden típus',
'tours.stats.byType': 'Fajta szerint',
'tours.stats.showTours': 'Túrák megjelenítése',
'tours.stats.none': 'Ebben az időszakban nincs teljesített túra',
'tours.stats.empty': 'Még nincs teljesített túra',
'tours.stats.upcoming': 'Még hátravan',
'tours.stats.upcomingHint': 'Egy túra akkor számít, ha a napja véget ért',
'tours.stats.unassigned': 'Nap nélkül',
'tours.stats.unassignedHint': 'Az utazásaid egyetlen napon sem szereplő túrái nem számítanak',
'tours.stats.undated': 'Dátum nélkül',
'tours.stats.undatedHint': 'A dátum nélküli napokon lévő túrák nem számítanak',
'tours.stats.multiDayHint': 'Több napra tervezve, egyszer számolva',
'tours.stats.exclude': 'Ne számítson',
'tours.stats.excludeHint': 'Elmaradt túrához. A tervben marad, csak itt nem számít tovább',
'tours.stats.include': 'Számítson újra',
'tours.stats.excluded': 'Nem számít',
'tours.stats.excludeFailed': 'A túrát nem sikerült módosítani',
'tours.stats.unavailable': 'A teljesített túrákat nem sikerült betölteni',
'tours.stats.openTrip': 'Utazás megnyitása',
};
export default tours;
+32
View File
@@ -1226,5 +1226,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Bandingkan di peta',
'tours.activity.compare.showHint': 'Menggambar jejak aktual bertitik di samping rencana. Tidak ada yang berubah.',
'tours.activity.compare.hide': 'Akhiri perbandingan',
'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.',
'tours.stats.basisPlan': 'Direncanakan',
'tours.stats.basisPlanHint': 'Dari perencanaan perjalanan, tidak diukur',
'tours.stats.basisTrack': 'Terekam',
'tours.stats.basisTrackHint': 'Dari jejak aktual yang ditautkan {name}',
'tours.stats.basisTrackCountHint': 'Jejak aktual yang ditautkan menggantikan nilai rencana',
'tours.stats.period': 'Periode',
'tours.stats.period.all': 'Sepanjang waktu',
'tours.stats.period.thisYear': 'Tahun ini',
'tours.stats.period.last12': '12 bulan terakhir',
'tours.stats.allTypes': 'Semua jenis',
'tours.stats.byType': 'Menurut jenis',
'tours.stats.showTours': 'Tampilkan tur',
'tours.stats.none': 'Tidak ada tur selesai pada periode ini',
'tours.stats.empty': 'Belum ada tur yang selesai',
'tours.stats.upcoming': 'Masih akan datang',
'tours.stats.upcomingHint': 'Tur dihitung setelah harinya berakhir',
'tours.stats.unassigned': 'Tanpa hari',
'tours.stats.unassignedHint': 'Tur di perjalananmu yang tidak berada di hari mana pun tidak dihitung',
'tours.stats.undated': 'Tanpa tanggal',
'tours.stats.undatedHint': 'Tur pada hari tanpa tanggal tidak dihitung',
'tours.stats.multiDayHint': 'Direncanakan di beberapa hari, dihitung sekali',
'tours.stats.exclude': 'Jangan hitung',
'tours.stats.excludeHint': 'Untuk tur yang batal. Tur tetap ada di rencana dan hanya berhenti dihitung di sini',
'tours.stats.include': 'Hitung lagi',
'tours.stats.excluded': 'Tidak dihitung',
'tours.stats.excludeFailed': 'Tur tidak dapat diubah',
'tours.stats.unavailable': 'Tur yang sudah dilakukan tidak dapat dimuat',
'tours.stats.openTrip': 'Buka perjalanan',
};
export default tours;
+32
View File
@@ -1254,5 +1254,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Confronta sulla mappa',
'tours.activity.compare.showHint': 'Disegna la traccia effettiva a puntini accanto al piano. Non cambia nulla.',
'tours.activity.compare.hide': 'Termina il confronto',
'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.',
'tours.stats.basisPlan': 'Pianificato',
'tours.stats.basisPlanHint': 'Dalla pianificazione del viaggio, non misurato',
'tours.stats.basisTrack': 'Registrato',
'tours.stats.basisTrackHint': 'Dalla traccia effettiva collegata {name}',
'tours.stats.basisTrackCountHint': 'Le tracce effettive collegate sostituiscono i valori pianificati',
'tours.stats.period': 'Periodo',
'tours.stats.period.all': 'Da sempre',
'tours.stats.period.thisYear': 'Quest’anno',
'tours.stats.period.last12': 'Ultimi 12 mesi',
'tours.stats.allTypes': 'Tutti i tipi',
'tours.stats.byType': 'Per tipo',
'tours.stats.showTours': 'Mostra escursioni',
'tours.stats.none': 'Nessuna escursione conclusa in questo periodo',
'tours.stats.empty': 'Ancora nessuna escursione conclusa',
'tours.stats.upcoming': 'Ancora da fare',
'tours.stats.upcomingHint': 'Un’escursione conta quando il suo giorno è finito',
'tours.stats.unassigned': 'Senza giorno',
'tours.stats.unassignedHint': 'Le escursioni dei tuoi viaggi che non sono in nessun giorno non contano',
'tours.stats.undated': 'Senza data',
'tours.stats.undatedHint': 'Le escursioni nei giorni senza data non contano',
'tours.stats.multiDayHint': 'Pianificata su più giorni, contata una volta',
'tours.stats.exclude': 'Non contare',
'tours.stats.excludeHint': 'Per un’escursione saltata. Resta nel piano e smette solo di contare qui',
'tours.stats.include': 'Conta di nuovo',
'tours.stats.excluded': 'Non contata',
'tours.stats.excludeFailed': 'Non è stato possibile modificare l’escursione',
'tours.stats.unavailable': 'Non è stato possibile caricare le escursioni svolte',
'tours.stats.openTrip': 'Apri viaggio',
};
export default tours;
+32
View File
@@ -1190,5 +1190,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': '地図で比較',
'tours.activity.compare.showHint': '実際のトラックを予定の横に点線で描きます。何も変更されません。',
'tours.activity.compare.hide': '比較を終了',
'tours.stats.title': '自分のツアー実績',
'tours.stats.sub': '旅行の計画に基づく値。リンクした実際のトラックがあればそちらに置き換わります',
'tours.stats.basisInfo':
'ツアーはその日が終わると、計画の値で集計されます。リンクした実際のトラックがあればその値に置き換わります。参加している日だけが集計されます。',
'tours.stats.basisPlan': '計画',
'tours.stats.basisPlanHint': '旅行の計画による値で、計測値ではありません',
'tours.stats.basisTrack': '記録',
'tours.stats.basisTrackHint': 'リンクした実際のトラック {name} の値',
'tours.stats.basisTrackCountHint': 'リンクした実際のトラックが計画の値に置き換わります',
'tours.stats.period': '期間',
'tours.stats.period.all': '全期間',
'tours.stats.period.thisYear': '今年',
'tours.stats.period.last12': '過去12か月',
'tours.stats.allTypes': 'すべての種類',
'tours.stats.byType': '種類別',
'tours.stats.showTours': 'ツアーを表示',
'tours.stats.none': 'この期間に完了したツアーはありません',
'tours.stats.empty': '完了したツアーはまだありません',
'tours.stats.upcoming': 'これから',
'tours.stats.upcomingHint': 'ツアーはその日が終わると集計されます',
'tours.stats.unassigned': '日なし',
'tours.stats.unassignedHint': 'どの日にも入っていない旅行のツアーは集計されません',
'tours.stats.undated': '日付なし',
'tours.stats.undatedHint': '日付のない日のツアーは集計されません',
'tours.stats.multiDayHint': '複数の日に計画、1回として集計',
'tours.stats.exclude': '集計しない',
'tours.stats.excludeHint': '中止になったツアー向け。計画には残り、ここで集計されなくなるだけです',
'tours.stats.include': '再び集計',
'tours.stats.excluded': '集計外',
'tours.stats.excludeFailed': 'ツアーを変更できませんでした',
'tours.stats.unavailable': 'ツアー実績を読み込めませんでした',
'tours.stats.openTrip': '旅行を開く',
};
export default tours;
+32
View File
@@ -1188,5 +1188,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': '지도에서 비교',
'tours.activity.compare.showHint': '실제 트랙을 계획 옆에 점선으로 그립니다. 아무것도 바뀌지 않습니다.',
'tours.activity.compare.hide': '비교 종료',
'tours.stats.title': '나의 투어 기록',
'tours.stats.sub': '여행 계획 기준이며, 연결된 실제 트랙이 있으면 그것으로 대체됩니다',
'tours.stats.basisInfo':
'투어는 해당 날짜가 지나면 계획 값으로 집계됩니다. 연결된 실제 트랙이 있으면 그 값으로 대체됩니다. 참여하는 날만 집계됩니다.',
'tours.stats.basisPlan': '계획',
'tours.stats.basisPlanHint': '여행 계획의 값이며 측정값이 아닙니다',
'tours.stats.basisTrack': '기록',
'tours.stats.basisTrackHint': '연결된 실제 트랙 {name}의 값',
'tours.stats.basisTrackCountHint': '연결된 실제 트랙이 계획 값을 대체합니다',
'tours.stats.period': '기간',
'tours.stats.period.all': '전체 기간',
'tours.stats.period.thisYear': '올해',
'tours.stats.period.last12': '최근 12개월',
'tours.stats.allTypes': '모든 유형',
'tours.stats.byType': '종류별',
'tours.stats.showTours': '투어 보기',
'tours.stats.none': '이 기간에 완료한 투어가 없습니다',
'tours.stats.empty': '아직 완료한 투어가 없습니다',
'tours.stats.upcoming': '예정',
'tours.stats.upcomingHint': '투어는 해당 날짜가 지나면 집계됩니다',
'tours.stats.unassigned': '날짜 미지정',
'tours.stats.unassignedHint': '어느 날에도 배정되지 않은 여행의 투어는 집계되지 않습니다',
'tours.stats.undated': '날짜 없음',
'tours.stats.undatedHint': '날짜가 없는 날의 투어는 집계되지 않습니다',
'tours.stats.multiDayHint': '여러 날에 계획, 한 번만 집계',
'tours.stats.exclude': '집계 제외',
'tours.stats.excludeHint': '취소된 투어용입니다. 계획에는 남아 있고 여기서만 집계되지 않습니다',
'tours.stats.include': '다시 집계',
'tours.stats.excluded': '집계 제외됨',
'tours.stats.excludeFailed': '투어를 변경할 수 없습니다',
'tours.stats.unavailable': '투어 기록을 불러올 수 없습니다',
'tours.stats.openTrip': '여행 열기',
};
export default tours;
+33
View File
@@ -1247,5 +1247,38 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Vergelijken op de kaart',
'tours.activity.compare.showHint': 'Tekent de werkelijke track gestippeld naast de planning. Er verandert niets.',
'tours.activity.compare.hide': 'Vergelijking beëindigen',
'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.',
'tours.stats.basisPlan': 'Gepland',
'tours.stats.basisPlanHint': 'Uit je reisplanning, niet gemeten',
'tours.stats.basisTrack': 'Opgenomen',
'tours.stats.basisTrackHint': 'Uit de gekoppelde werkelijke track {name}',
'tours.stats.basisTrackCountHint': 'Gekoppelde werkelijke tracks vervangen de geplande waarden',
'tours.stats.period': 'Periode',
'tours.stats.period.all': 'Altijd',
'tours.stats.period.thisYear': 'Dit jaar',
'tours.stats.period.last12': 'Afgelopen 12 maanden',
'tours.stats.allTypes': 'Alle soorten',
'tours.stats.byType': 'Per soort',
'tours.stats.showTours': 'Tochten tonen',
'tours.stats.none': 'Geen afgeronde tochten in deze periode',
'tours.stats.empty': 'Nog geen afgeronde tochten',
'tours.stats.upcoming': 'Nog te gaan',
'tours.stats.upcomingHint': 'Een tocht telt zodra zijn dag voorbij is',
'tours.stats.unassigned': 'Zonder dag',
'tours.stats.unassignedHint': 'Tochten van je reizen die op geen enkele dag staan, tellen niet',
'tours.stats.undated': 'Zonder datum',
'tours.stats.undatedHint': 'Tochten op dagen zonder datum tellen niet',
'tours.stats.multiDayHint': 'Op meerdere dagen gepland, één keer geteld',
'tours.stats.exclude': 'Niet meetellen',
'tours.stats.excludeHint':
'Voor een tocht die niet doorging. Hij blijft in de planning en telt alleen hier niet meer',
'tours.stats.include': 'Weer meetellen',
'tours.stats.excluded': 'Niet geteld',
'tours.stats.excludeFailed': 'De tocht kon niet worden gewijzigd',
'tours.stats.unavailable': 'Gemaakte tochten konden niet worden geladen',
'tours.stats.openTrip': 'Reis openen',
};
export default tours;
+32
View File
@@ -1268,5 +1268,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Porównaj na mapie',
'tours.activity.compare.showHint': 'Rysuje rzeczywistą trasę kropkami obok planu. Nic się nie zmienia.',
'tours.activity.compare.hide': 'Zakończ porównanie',
'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ł.',
'tours.stats.basisPlan': 'Zaplanowane',
'tours.stats.basisPlanHint': 'Z planu podróży, nie zmierzone',
'tours.stats.basisTrack': 'Nagrane',
'tours.stats.basisTrackHint': 'Z połączonej rzeczywistej trasy {name}',
'tours.stats.basisTrackCountHint': 'Połączone rzeczywiste trasy zastępują zaplanowane wartości',
'tours.stats.period': 'Okres',
'tours.stats.period.all': 'Od początku',
'tours.stats.period.thisYear': 'Ten rok',
'tours.stats.period.last12': 'Ostatnie 12 miesięcy',
'tours.stats.allTypes': 'Wszystkie typy',
'tours.stats.byType': 'Według rodzaju',
'tours.stats.showTours': 'Pokaż wycieczki',
'tours.stats.none': 'Brak ukończonych wycieczek w tym okresie',
'tours.stats.empty': 'Brak ukończonych wycieczek',
'tours.stats.upcoming': 'Jeszcze przed tobą',
'tours.stats.upcomingHint': 'Wycieczka liczy się, gdy jej dzień się skończy',
'tours.stats.unassigned': 'Bez dnia',
'tours.stats.unassignedHint': 'Wycieczki twoich podróży, które nie są w żadnym dniu, się nie liczą',
'tours.stats.undated': 'Bez daty',
'tours.stats.undatedHint': 'Wycieczki w dniach bez daty się nie liczą',
'tours.stats.multiDayHint': 'Zaplanowana na kilka dni, liczona raz',
'tours.stats.exclude': 'Nie liczyć',
'tours.stats.excludeHint': 'Dla wycieczki, która się nie odbyła. Zostaje w planie i tylko tutaj przestaje się liczyć',
'tours.stats.include': 'Licz ponownie',
'tours.stats.excluded': 'Nieliczona',
'tours.stats.excludeFailed': 'Nie udało się zmienić wycieczki',
'tours.stats.unavailable': 'Nie udało się wczytać odbytych wycieczek',
'tours.stats.openTrip': 'Otwórz podróż',
};
export default tours;
+32
View File
@@ -1275,5 +1275,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Сравнить на карте',
'tours.activity.compare.showHint': 'Рисует фактический трек пунктиром рядом с планом. Ничего не меняется.',
'tours.activity.compare.hide': 'Завершить сравнение',
'tours.stats.title': 'Мои пройденные туры',
'tours.stats.sub': 'По планированию поездки; привязанные фактические треки заменяют его',
'tours.stats.basisInfo':
'Тур засчитывается, когда его день закончился, со значениями из твоего планирования. Привязанный фактический трек их заменяет. Учитываются только дни, в которых ты участвуешь.',
'tours.stats.basisPlan': 'Запланировано',
'tours.stats.basisPlanHint': 'Из планирования поездки, не измерено',
'tours.stats.basisTrack': 'Записано',
'tours.stats.basisTrackHint': 'Из привязанного фактического трека {name}',
'tours.stats.basisTrackCountHint': 'Привязанные фактические треки заменяют запланированные значения',
'tours.stats.period': 'Период',
'tours.stats.period.all': 'За всё время',
'tours.stats.period.thisYear': 'В этом году',
'tours.stats.period.last12': 'Последние 12 месяцев',
'tours.stats.allTypes': 'Все типы',
'tours.stats.byType': 'По типам',
'tours.stats.showTours': 'Показать туры',
'tours.stats.none': 'Нет пройденных туров за этот период',
'tours.stats.empty': 'Пока нет пройденных туров',
'tours.stats.upcoming': 'Ещё впереди',
'tours.stats.upcomingHint': 'Тур засчитывается, когда его день закончился',
'tours.stats.unassigned': 'Без дня',
'tours.stats.unassignedHint': 'Туры твоих поездок, которые не стоят ни на одном дне, не учитываются',
'tours.stats.undated': 'Без даты',
'tours.stats.undatedHint': 'Туры в днях без даты не учитываются',
'tours.stats.multiDayHint': 'Запланирован на несколько дней, учтён один раз',
'tours.stats.exclude': 'Не учитывать',
'tours.stats.excludeHint': 'Для тура, который не состоялся. Он остаётся в плане и только перестаёт учитываться здесь',
'tours.stats.include': 'Учитывать снова',
'tours.stats.excluded': 'Не учтён',
'tours.stats.excludeFailed': 'Не удалось изменить тур',
'tours.stats.unavailable': 'Не удалось загрузить пройденные туры',
'tours.stats.openTrip': 'Открыть поездку',
};
export default tours;
+32
View File
@@ -1230,5 +1230,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Porovnať na mape',
'tours.activity.compare.showHint': 'Vykreslí skutočnú trasu bodkovane vedľa plánu. Nič sa nemení.',
'tours.activity.compare.hide': 'Ukončiť porovnanie',
'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š.',
'tours.stats.basisPlan': 'Plánované',
'tours.stats.basisPlanHint': 'Z plánu cesty, nemerané',
'tours.stats.basisTrack': 'Zaznamenané',
'tours.stats.basisTrackHint': 'Z prepojenej skutočnej trasy {name}',
'tours.stats.basisTrackCountHint': 'Prepojené skutočné trasy nahrádzajú plánované hodnoty',
'tours.stats.period': 'Obdobie',
'tours.stats.period.all': 'Celkovo',
'tours.stats.period.thisYear': 'Tento rok',
'tours.stats.period.last12': 'Posledných 12 mesiacov',
'tours.stats.allTypes': 'Všetky typy',
'tours.stats.byType': 'Podľa druhu',
'tours.stats.showTours': 'Zobraziť túry',
'tours.stats.none': 'V tomto období žiadne absolvované túry',
'tours.stats.empty': 'Zatiaľ žiadne absolvované túry',
'tours.stats.upcoming': 'Ešte pred tebou',
'tours.stats.upcomingHint': 'Túra sa započíta, keď jej deň skončí',
'tours.stats.unassigned': 'Bez dňa',
'tours.stats.unassignedHint': 'Túry tvojich ciest, ktoré nie sú v žiadnom dni, sa nepočítajú',
'tours.stats.undated': 'Bez dátumu',
'tours.stats.undatedHint': 'Túry v dňoch bez dátumu sa nepočítajú',
'tours.stats.multiDayHint': 'Naplánovaná na viac dní, započítaná raz',
'tours.stats.exclude': 'Nezapočítavať',
'tours.stats.excludeHint': 'Pre túru, ktorá sa nekonala. Zostane v pláne a len sa tu prestane počítať',
'tours.stats.include': 'Znova započítať',
'tours.stats.excluded': 'Nezapočítané',
'tours.stats.excludeFailed': 'Túru sa nepodarilo zmeniť',
'tours.stats.unavailable': 'Absolvované túry sa nepodarilo načítať',
'tours.stats.openTrip': 'Otvoriť cestu',
};
export default tours;
+32
View File
@@ -1219,5 +1219,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Jämför på kartan',
'tours.activity.compare.showHint': 'Ritar det faktiska spåret prickat bredvid planen. Inget ändras.',
'tours.activity.compare.hide': 'Avsluta jämförelsen',
'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.',
'tours.stats.basisPlan': 'Planerat',
'tours.stats.basisPlanHint': 'Från reseplaneringen, inte uppmätt',
'tours.stats.basisTrack': 'Inspelat',
'tours.stats.basisTrackHint': 'Från det länkade faktiska spåret {name}',
'tours.stats.basisTrackCountHint': 'Länkade faktiska spår ersätter de planerade värdena',
'tours.stats.period': 'Period', // same-as-en
'tours.stats.period.all': 'Hela tiden',
'tours.stats.period.thisYear': 'I år',
'tours.stats.period.last12': 'Senaste 12 månaderna',
'tours.stats.allTypes': 'Alla typer',
'tours.stats.byType': 'Per typ',
'tours.stats.showTours': 'Visa turer',
'tours.stats.none': 'Inga genomförda turer under den här perioden',
'tours.stats.empty': 'Inga genomförda turer än',
'tours.stats.upcoming': 'Ligger framför',
'tours.stats.upcomingHint': 'En tur räknas när dess dag är över',
'tours.stats.unassigned': 'Utan dag',
'tours.stats.unassignedHint': 'Turer i dina resor som inte ligger på någon dag räknas inte',
'tours.stats.undated': 'Utan datum',
'tours.stats.undatedHint': 'Turer på dagar utan datum räknas inte',
'tours.stats.multiDayHint': 'Planerad på flera dagar, räknad en gång',
'tours.stats.exclude': 'Räkna inte',
'tours.stats.excludeHint': 'För en tur som blev inställd. Den finns kvar i planen och slutar bara räknas här',
'tours.stats.include': 'Räkna igen',
'tours.stats.excluded': 'Inte räknad',
'tours.stats.excludeFailed': 'Turen kunde inte ändras',
'tours.stats.unavailable': 'Genomförda turer kunde inte laddas',
'tours.stats.openTrip': 'Öppna resan',
};
export default tours;
+32
View File
@@ -1171,5 +1171,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'เปรียบเทียบบนแผนที่',
'tours.activity.compare.showHint': 'วาดเส้นทางจริงเป็นจุดข้างแผน ไม่มีอะไรเปลี่ยน',
'tours.activity.compare.hide': 'จบการเปรียบเทียบ',
'tours.stats.title': 'ทัวร์ที่ฉันทำแล้ว',
'tours.stats.sub': 'อิงจากการวางแผนทริป เส้นทางจริงที่เชื่อมไว้จะใช้แทน',
'tours.stats.basisInfo':
'ทัวร์จะนับเมื่อวันของทัวร์ผ่านไปแล้ว โดยใช้ค่าจากการวางแผนของคุณ เส้นทางจริงที่เชื่อมไว้จะใช้แทนค่าเหล่านั้น นับเฉพาะวันที่คุณเข้าร่วม',
'tours.stats.basisPlan': 'ตามแผน',
'tours.stats.basisPlanHint': 'จากการวางแผนทริป ไม่ได้วัดจริง',
'tours.stats.basisTrack': 'บันทึกจริง',
'tours.stats.basisTrackHint': 'จากเส้นทางจริงที่เชื่อมไว้ {name}',
'tours.stats.basisTrackCountHint': 'เส้นทางจริงที่เชื่อมไว้ใช้แทนค่าตามแผน',
'tours.stats.period': 'ช่วงเวลา',
'tours.stats.period.all': 'ทั้งหมด',
'tours.stats.period.thisYear': 'ปีนี้',
'tours.stats.period.last12': '12 เดือนล่าสุด',
'tours.stats.allTypes': 'ทุกประเภท',
'tours.stats.byType': 'ตามประเภท',
'tours.stats.showTours': 'แสดงทัวร์',
'tours.stats.none': 'ไม่มีทัวร์ที่เสร็จแล้วในช่วงนี้',
'tours.stats.empty': 'ยังไม่มีทัวร์ที่เสร็จแล้ว',
'tours.stats.upcoming': 'ยังมาไม่ถึง',
'tours.stats.upcomingHint': 'ทัวร์จะนับเมื่อวันของทัวร์ผ่านไปแล้ว',
'tours.stats.unassigned': 'ไม่มีวัน',
'tours.stats.unassignedHint': 'ทัวร์ในทริปของคุณที่ไม่ได้อยู่ในวันใดจะไม่ถูกนับ',
'tours.stats.undated': 'ไม่มีวันที่',
'tours.stats.undatedHint': 'ทัวร์ในวันที่ไม่มีวันที่จะไม่ถูกนับ',
'tours.stats.multiDayHint': 'วางแผนไว้หลายวัน นับครั้งเดียว',
'tours.stats.exclude': 'ไม่นับ',
'tours.stats.excludeHint': 'สำหรับทัวร์ที่ยกเลิก ทัวร์ยังอยู่ในแผนและเพียงไม่ถูกนับที่นี่',
'tours.stats.include': 'นับอีกครั้ง',
'tours.stats.excluded': 'ไม่นับแล้ว',
'tours.stats.excludeFailed': 'เปลี่ยนทัวร์ไม่ได้',
'tours.stats.unavailable': 'โหลดทัวร์ที่ทำแล้วไม่ได้',
'tours.stats.openTrip': 'เปิดทริป',
};
export default tours;
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 1113;
const GENERAL_KEY_COUNT = 1144;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+32
View File
@@ -1226,5 +1226,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Haritada karşılaştır',
'tours.activity.compare.showHint': 'Gerçek izi planın yanına noktalı çizer. Hiçbir şey değişmez.',
'tours.activity.compare.hide': 'Karşılaştırmayı bitir',
'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.',
'tours.stats.basisPlan': 'Planlanan',
'tours.stats.basisPlanHint': 'Seyahat planından, ölçülmedi',
'tours.stats.basisTrack': 'Kaydedilen',
'tours.stats.basisTrackHint': 'Bağlanan gerçek izden: {name}',
'tours.stats.basisTrackCountHint': 'Bağlanan gerçek izler planlanan değerlerin yerini alır',
'tours.stats.period': 'Dönem',
'tours.stats.period.all': 'Tüm zamanlar',
'tours.stats.period.thisYear': 'Bu yıl',
'tours.stats.period.last12': 'Son 12 ay',
'tours.stats.allTypes': 'Tüm türler',
'tours.stats.byType': 'Türe göre',
'tours.stats.showTours': 'Turları göster',
'tours.stats.none': 'Bu dönemde tamamlanan tur yok',
'tours.stats.empty': 'Henüz tamamlanan tur yok',
'tours.stats.upcoming': 'Önünde',
'tours.stats.upcomingHint': 'Bir tur, günü bittiğinde sayılır',
'tours.stats.unassigned': 'Günsüz',
'tours.stats.unassignedHint': 'Seyahatlerinde hiçbir günde olmayan turlar sayılmaz',
'tours.stats.undated': 'Tarihsiz',
'tours.stats.undatedHint': 'Tarihi olmayan günlerdeki turlar sayılmaz',
'tours.stats.multiDayHint': 'Birkaç güne planlandı, bir kez sayıldı',
'tours.stats.exclude': 'Sayma',
'tours.stats.excludeHint': 'Gerçekleşmeyen bir tur için. Planda kalır, yalnızca burada sayılmayı bırakır',
'tours.stats.include': 'Yeniden say',
'tours.stats.excluded': 'Sayılmadı',
'tours.stats.excludeFailed': 'Tur değiştirilemedi',
'tours.stats.unavailable': 'Tamamlanan turlar yüklenemedi',
'tours.stats.openTrip': 'Seyahati aç',
};
export default tours;
+32
View File
@@ -1261,5 +1261,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'Порівняти на карті',
'tours.activity.compare.showHint': 'Малює фактичний трек пунктиром поруч із планом. Нічого не змінюється.',
'tours.activity.compare.hide': 'Завершити порівняння',
'tours.stats.title': 'Мої пройдені тури',
'tours.stats.sub': 'За плануванням подорожі; прив’язані фактичні треки замінюють його',
'tours.stats.basisInfo':
'Тур зараховується, коли його день завершився, зі значеннями з твого планування. Прив’язаний фактичний трек їх замінює. Враховуються лише дні, у яких ти береш участь.',
'tours.stats.basisPlan': 'Заплановано',
'tours.stats.basisPlanHint': 'З планування подорожі, не виміряно',
'tours.stats.basisTrack': 'Записано',
'tours.stats.basisTrackHint': 'З прив’язаного фактичного треку {name}',
'tours.stats.basisTrackCountHint': 'Прив’язані фактичні треки замінюють заплановані значення',
'tours.stats.period': 'Період',
'tours.stats.period.all': 'За весь час',
'tours.stats.period.thisYear': 'Цього року',
'tours.stats.period.last12': 'Останні 12 місяців',
'tours.stats.allTypes': 'Усі типи',
'tours.stats.byType': 'За типами',
'tours.stats.showTours': 'Показати тури',
'tours.stats.none': 'Немає пройдених турів за цей період',
'tours.stats.empty': 'Поки немає пройдених турів',
'tours.stats.upcoming': 'Ще попереду',
'tours.stats.upcomingHint': 'Тур зараховується, коли його день завершився',
'tours.stats.unassigned': 'Без дня',
'tours.stats.unassignedHint': 'Тури твоїх подорожей, які не стоять у жодному дні, не враховуються',
'tours.stats.undated': 'Без дати',
'tours.stats.undatedHint': 'Тури в днях без дати не враховуються',
'tours.stats.multiDayHint': 'Заплановано на кілька днів, враховано один раз',
'tours.stats.exclude': 'Не враховувати',
'tours.stats.excludeHint': 'Для туру, який не відбувся. Він залишається в плані й лише перестає враховуватися тут',
'tours.stats.include': 'Враховувати знову',
'tours.stats.excluded': 'Не враховано',
'tours.stats.excludeFailed': 'Не вдалося змінити тур',
'tours.stats.unavailable': 'Не вдалося завантажити пройдені тури',
'tours.stats.openTrip': 'Відкрити подорож',
};
export default tours;
+32
View File
@@ -1215,5 +1215,37 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': 'So sánh trên bản đồ',
'tours.activity.compare.showHint': 'Vẽ lộ trình thực tế bằng nét chấm cạnh kế hoạch. Không có gì thay đổi.',
'tours.activity.compare.hide': 'Kết thúc so sánh',
'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.',
'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',
'tours.stats.basisTrackHint': 'Từ lộ trình thực tế được liên kết {name}',
'tours.stats.basisTrackCountHint': 'Lộ trình thực tế được liên kết thay thế các giá trị kế hoạch',
'tours.stats.period': 'Khoảng thời gian',
'tours.stats.period.all': 'Mọi lúc',
'tours.stats.period.thisYear': 'Năm nay',
'tours.stats.period.last12': '12 tháng qua',
'tours.stats.allTypes': 'Tất cả loại',
'tours.stats.byType': 'Theo loại',
'tours.stats.showTours': 'Xem các chuyến',
'tours.stats.none': 'Không có chuyến hoàn thành trong khoảng này',
'tours.stats.empty': 'Chưa có chuyến nào hoàn thành',
'tours.stats.upcoming': 'Sắp tới',
'tours.stats.upcomingHint': 'Một chuyến được tính khi ngày của nó đã qua',
'tours.stats.unassigned': 'Không có ngày',
'tours.stats.unassignedHint': 'Các chuyến trong chuyến đi của bạn không nằm ở ngày nào sẽ không được tính',
'tours.stats.undated': 'Không có ngày tháng',
'tours.stats.undatedHint': 'Các chuyến vào ngày không có ngày tháng sẽ không được tính',
'tours.stats.multiDayHint': 'Lên kế hoạch nhiều ngày, chỉ tính một lần',
'tours.stats.exclude': 'Không tính',
'tours.stats.excludeHint': 'Dành cho chuyến bị hủy. Chuyến vẫn ở trong kế hoạch và chỉ không được tính ở đây',
'tours.stats.include': 'Tính lại',
'tours.stats.excluded': 'Không được tính',
'tours.stats.excludeFailed': 'Không thể thay đổi chuyến',
'tours.stats.unavailable': 'Không thể tải các chuyến đã đi',
'tours.stats.openTrip': 'Mở chuyến đi',
};
export default tours;
+31
View File
@@ -1131,5 +1131,36 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': '在地圖上比較',
'tours.activity.compare.showHint': '將實際軌跡以點線畫在計畫旁。不會變更任何內容。',
'tours.activity.compare.hide': '結束比較',
'tours.stats.title': '我的遊程紀錄',
'tours.stats.sub': '以你的行程規劃為準;已連結的實際軌跡會取代規劃值',
'tours.stats.basisInfo': '遊程在其日期結束後計入,使用你的規劃值。已連結的實際軌跡會取代這些值。只計入你參加的日子。',
'tours.stats.basisPlan': '規劃',
'tours.stats.basisPlanHint': '來自行程規劃,並非實測',
'tours.stats.basisTrack': '已記錄',
'tours.stats.basisTrackHint': '來自已連結的實際軌跡 {name}',
'tours.stats.basisTrackCountHint': '已連結的實際軌跡取代規劃值',
'tours.stats.period': '時間範圍',
'tours.stats.period.all': '全部時間',
'tours.stats.period.thisYear': '今年',
'tours.stats.period.last12': '最近 12 個月',
'tours.stats.allTypes': '所有類型',
'tours.stats.byType': '依類型',
'tours.stats.showTours': '查看遊程',
'tours.stats.none': '此時間範圍內沒有已完成的遊程',
'tours.stats.empty': '尚無已完成的遊程',
'tours.stats.upcoming': '即將進行',
'tours.stats.upcomingHint': '遊程在其日期結束後計入',
'tours.stats.unassigned': '未排入日期',
'tours.stats.unassignedHint': '行程中未排入任何一天的遊程不計入',
'tours.stats.undated': '無日期',
'tours.stats.undatedHint': '沒有日期的日子裡的遊程不計入',
'tours.stats.multiDayHint': '排在多天,只計一次',
'tours.stats.exclude': '不計入',
'tours.stats.excludeHint': '用於取消的遊程。它仍保留在規劃中,只是不再在這裡計入',
'tours.stats.include': '重新計入',
'tours.stats.excluded': '未計入',
'tours.stats.excludeFailed': '無法變更遊程',
'tours.stats.unavailable': '無法載入遊程紀錄',
'tours.stats.openTrip': '開啟行程',
};
export default tours;
+31
View File
@@ -1132,5 +1132,36 @@ const tours: TranslationStrings = {
'tours.activity.compare.show': '在地图上比较',
'tours.activity.compare.showHint': '将实际轨迹以点线画在计划旁。不会更改任何内容。',
'tours.activity.compare.hide': '结束比较',
'tours.stats.title': '我的游览记录',
'tours.stats.sub': '基于你的行程规划;已关联的实际轨迹会替代规划值',
'tours.stats.basisInfo': '游览在其日期结束后计入,使用你的规划值。已关联的实际轨迹会替代这些值。只计入你参加的日子。',
'tours.stats.basisPlan': '规划',
'tours.stats.basisPlanHint': '来自行程规划,并非实测',
'tours.stats.basisTrack': '已记录',
'tours.stats.basisTrackHint': '来自已关联的实际轨迹 {name}',
'tours.stats.basisTrackCountHint': '已关联的实际轨迹替代规划值',
'tours.stats.period': '时间段',
'tours.stats.period.all': '全部时间',
'tours.stats.period.thisYear': '今年',
'tours.stats.period.last12': '最近 12 个月',
'tours.stats.allTypes': '所有类型',
'tours.stats.byType': '按类型',
'tours.stats.showTours': '查看游览',
'tours.stats.none': '此时间段内没有已完成的游览',
'tours.stats.empty': '还没有已完成的游览',
'tours.stats.upcoming': '即将进行',
'tours.stats.upcomingHint': '游览在其日期结束后计入',
'tours.stats.unassigned': '未安排日期',
'tours.stats.unassignedHint': '行程中未安排到任何一天的游览不计入',
'tours.stats.undated': '无日期',
'tours.stats.undatedHint': '没有日期的日子里的游览不计入',
'tours.stats.multiDayHint': '安排在多天,只计一次',
'tours.stats.exclude': '不计入',
'tours.stats.excludeHint': '用于取消的游览。它仍保留在规划中,只是不再在这里计入',
'tours.stats.include': '重新计入',
'tours.stats.excluded': '未计入',
'tours.stats.excludeFailed': '无法更改游览',
'tours.stats.unavailable': '无法加载游览记录',
'tours.stats.openTrip': '打开行程',
};
export default tours;
+2
View File
@@ -59,6 +59,8 @@ export * from './tours/run-pace';
export * from './tours/ski.schema';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-activity.schema';
export * from './tours/tour-activity-stats.schema';
export * from './tours/tour-activity-stats';
export * from './tours/tour-library.schema';
export * from './tours/tour-poi-search.schema';
export * from './tours/tour-trail-grade.schema';
@@ -0,0 +1,89 @@
import { idSchema } from '../common/primitives.schema';
import { tourTypeKeySchema } from './tours.schema';
import { z } from 'zod';
/**
* STA-02: the user's own tour activities, as the Atlas sums them. One entry is
* one tour the user did on one day of a trip, read from what the trip holds:
*
* - 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.
* - 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.
* - 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.
*
* 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
* upcoming ones too, so the app can say how many are still ahead.
*/
/** Where an entry's values come from: the trip's planning, or a track recorded while doing it. */
export const TOUR_ACTIVITY_BASES = ['plan', 'track'] as const;
export const tourActivityBasisSchema = z.enum(TOUR_ACTIVITY_BASES);
export type TourActivityBasis = z.infer<typeof tourActivityBasisSchema>;
/** A calendar day, YYYY-MM-DD, as a trip's days carry it. */
export const tourActivityDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
export const tourActivityStatsEntrySchema = z.object({
place_id: idSchema,
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. */
date: tourActivityDateSchema,
basis: tourActivityBasisSchema,
/** The recording whose values these are; null for the plan. */
track: z.object({ place_id: idSchema, name: z.string() }).nullable(),
/** On how many of the user's dated days the tour is planned; it counts once however many. */
day_count: z.number().int().positive(),
/** Null where the source does not know the value; a paddle tour never has heights. */
distance_km: z.number().nonnegative().nullable(),
ascent_m: z.number().nonnegative().nullable(),
descent_m: z.number().nonnegative().nullable(),
/** Left out of the user's sums on purpose (a tour that fell through); the plan stays as it is. */
excluded: z.boolean(),
});
export type TourActivityStatsEntry = z.infer<typeof tourActivityStatsEntrySchema>;
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.
*/
uncounted: z.object({
unassigned: z.number().int().nonnegative(),
undated: z.number().int().nonnegative(),
}),
});
export type TourActivityStatsResponse = z.infer<typeof tourActivityStatsResponseSchema>;
/** Leaving a tour out of the user's own sums, or counting it again. Nothing of the tour changes. */
export const tourActivityExclusionRequestSchema = z
.object({
place_id: idSchema,
excluded: z.boolean(),
})
.strict();
export type TourActivityExclusionRequest = z.infer<typeof tourActivityExclusionRequestSchema>;
/** What a reader narrows the sums by: a period (both days included) and one type. */
export const tourActivityStatsQuerySchema = z
.object({
from: tourActivityDateSchema.optional(),
to: tourActivityDateSchema.optional(),
type: tourTypeKeySchema.optional(),
})
.strict();
export type TourActivityStatsQuery = z.infer<typeof tourActivityStatsQuerySchema>;
@@ -0,0 +1,186 @@
import { isTourActivityDone, summarizeTourActivities } from './tour-activity-stats';
import {
tourActivityExclusionRequestSchema,
tourActivityStatsQuerySchema,
tourActivityStatsResponseSchema,
type TourActivityStatsEntry,
} from './tour-activity-stats.schema';
import { describe, expect, it } from 'vitest';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
const TODAY = '2026-10-20';
describe('STA-02 tour activity stats contract', () => {
it('STA2-SCHEMA-001: reads entries of either basis with unknown values kept unknown', () => {
const body = {
entries: [
entry(),
entry({
place_id: 2,
basis: 'track',
track: { place_id: 9, name: 'Watch export' },
distance_km: null,
ascent_m: null,
descent_m: null,
}),
],
uncounted: { unassigned: 1, undated: 0 },
};
expect(tourActivityStatsResponseSchema.parse(body)).toEqual(body);
});
it('STA2-SCHEMA-002: refuses a date that is not a calendar day, a negative distance and an unknown basis', () => {
const uncounted = { unassigned: 0, undated: 0 };
const parses = (entries: unknown[]) => tourActivityStatsResponseSchema.safeParse({ entries, uncounted }).success;
expect(parses([entry({ date: '02.07.2026' })])).toBe(false);
expect(parses([entry({ distance_km: -1 })])).toBe(false);
expect(parses([{ ...entry(), basis: 'guess' }])).toBe(false);
});
it('STA2-SCHEMA-003: the exclusion names one tour and a yes or no, nothing else', () => {
expect(tourActivityExclusionRequestSchema.parse({ place_id: 4, excluded: true })).toEqual({
place_id: 4,
excluded: true,
});
expect(tourActivityExclusionRequestSchema.safeParse({ place_id: 4 }).success).toBe(false);
const withNote = { place_id: 4, excluded: true, note: 'rain' };
expect(tourActivityExclusionRequestSchema.safeParse(withNote).success).toBe(false);
});
it('STA2-SCHEMA-004: the query takes a period and a type, all of them optional', () => {
expect(tourActivityStatsQuerySchema.parse({})).toEqual({});
expect(tourActivityStatsQuerySchema.parse({ from: '2026-01-01', to: '2026-12-31', type: 'kayak' })).toEqual({
from: '2026-01-01',
to: '2026-12-31',
type: 'kayak',
});
expect(tourActivityStatsQuerySchema.safeParse({ type: 'car' }).success).toBe(false);
});
});
describe('STA-02 summarizeTourActivities', () => {
it('STA2-SUM-001: a tour counts from the day after its day, not on it', () => {
expect(isTourActivityDone(entry({ date: '2026-10-19' }), TODAY)).toBe(true);
expect(isTourActivityDone(entry({ date: TODAY }), TODAY)).toBe(false);
const summary = summarizeTourActivities([entry({ date: '2026-10-19' }), entry({ place_id: 2, date: TODAY })], {
today: TODAY,
});
expect(summary.totals.count).toBe(1);
expect(summary.upcoming).toBe(1);
});
it('STA2-SUM-002: sums distance and heights, says how many rest on the plan and how many on a track', () => {
const summary = summarizeTourActivities(
[
entry(),
entry({
place_id: 2,
basis: 'track',
track: { place_id: 9, name: 'Watch' },
distance_km: 12.5,
ascent_m: 650,
descent_m: 600,
}),
],
{ today: TODAY },
);
expect(summary.totals).toEqual({
count: 2,
distanceKm: { value: 22.5, known: 2, of: 2 },
ascentM: { value: 1150, known: 2, of: 2 },
descentM: { value: 1000, known: 2, of: 2 },
byBasis: { plan: 1, track: 1 },
});
});
it('STA2-SUM-003: an unknown value is no zero: the sum says it is short of the whole, or null when nobody knows', () => {
const summary = summarizeTourActivities(
[
entry({ ascent_m: null, descent_m: null }),
entry({ place_id: 2, distance_km: null, ascent_m: null, descent_m: null }),
],
{ today: TODAY },
);
expect(summary.totals.distanceKm).toEqual({ value: 10, known: 1, of: 2 });
expect(summary.totals.ascentM).toEqual({ value: null, known: 0, of: 2 });
});
it('STA2-SUM-004: paddling sums no heights, so a paddle tour leaves the height sums complete', () => {
const summary = summarizeTourActivities(
[entry(), entry({ place_id: 2, tour_type: 'kayak', distance_km: 8, ascent_m: null, descent_m: null })],
{ today: TODAY },
);
expect(summary.totals.distanceKm).toEqual({ value: 18, known: 2, of: 2 });
expect(summary.totals.ascentM).toEqual({ value: 500, known: 1, of: 1 });
expect(summary.types.find((type) => type.type === 'kayak')?.ascentM).toEqual({ value: null, known: 0, of: 0 });
});
it('STA2-SUM-005: narrows by period (both days included) and by type, the most tours of a type first', () => {
const entries = [
entry({ place_id: 1, date: '2025-12-31' }),
entry({ place_id: 2, date: '2026-01-01', tour_type: 'bike' }),
entry({ place_id: 3, date: '2026-06-30', tour_type: 'kayak' }),
entry({ place_id: 4, date: '2026-07-01', tour_type: 'kayak' }),
];
const year = summarizeTourActivities(entries, { today: TODAY, from: '2026-01-01', to: '2026-12-31' });
expect(year.totals.count).toBe(3);
expect(year.types.map((type) => [type.type, type.count])).toEqual([
['kayak', 2],
['bike', 1],
]);
const paddled = summarizeTourActivities(entries, { today: TODAY, type: 'kayak' });
expect(paddled.totals.distanceKm.value).toBe(20);
expect(paddled.done.map((item) => item.place_id)).toEqual([4, 3]);
});
it('STA2-SUM-006: an excluded tour is listed among the done ones but never summed, nor counted as upcoming', () => {
const summary = summarizeTourActivities(
[
entry({ excluded: true }),
entry({ place_id: 2, date: '2026-07-03' }),
entry({ place_id: 3, date: '2026-12-01', excluded: true }),
],
{ today: TODAY },
);
expect(summary.totals.count).toBe(1);
expect(summary.done.map((item) => [item.place_id, item.excluded])).toEqual([
[2, false],
[1, true],
]);
expect(summary.upcoming).toBe(0);
});
it('STA2-SUM-007: nothing done is an empty summary, not a row of zeros', () => {
const summary = summarizeTourActivities([], { today: TODAY });
expect(summary.totals.count).toBe(0);
expect(summary.totals.distanceKm.value).toBeNull();
expect(summary.types).toEqual([]);
expect(summary.done).toEqual([]);
});
it('STA2-SUM-008: the same day sorts by name, so the list keeps its order between reads', () => {
const summary = summarizeTourActivities(
[entry({ place_id: 1, name: 'Zugspitze' }), entry({ place_id: 2, name: 'Alpspitze' })],
{ today: TODAY },
);
expect(summary.done.map((item) => item.name)).toEqual(['Alpspitze', 'Zugspitze']);
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { TourActivityBasis, TourActivityStatsEntry } from './tour-activity-stats.schema';
import type { TourTypeKey } from './tours.schema';
/**
* STA-02: what the user's done tours add up to, for the Atlas and for the
* assistant alike, so both say the same numbers. A sum knows how many entries
* had the value and of how many could: `value` is null when none had it, never
* a zero standing in for "unknown", and `known < of` says it is short of the
* whole. Paddling sums no heights (a climb on the water says nothing), as in
* the trip's own tour totals (STA-01).
*/
export interface TourActivitySum {
value: number | null;
known: number;
of: number;
}
export interface TourActivityTotals {
count: number;
distanceKm: TourActivitySum;
ascentM: TourActivitySum;
descentM: TourActivitySum;
/** How many of the entries rest on the plan and how many on a recorded track. */
byBasis: Record<TourActivityBasis, number>;
}
export interface TourActivityFilter {
/** Today in the reader's own calendar, YYYY-MM-DD: a tour counts once its day is over. */
today: string;
/** The period, both days included; left out, open on that side. */
from?: string | null;
to?: string | null;
/** One type; left out, all of them. */
type?: TourTypeKey | null;
}
export interface TourActivitySummary {
totals: TourActivityTotals;
/** By type, the most tours first; empty when nothing counts. */
types: Array<TourActivityTotals & { type: TourTypeKey }>;
/** Every entry done in the period, the newest first; those the user left out are among them, flagged and not summed. */
done: TourActivityStatsEntry[];
/** In the period and of the type, but their day is not over yet. */
upcoming: number;
}
/** Whether the day an entry is on is over: it counts from the next day on. */
export function isTourActivityDone(entry: Pick<TourActivityStatsEntry, 'date'>, today: string): boolean {
return entry.date < today;
}
function inPeriod(date: string, filter: TourActivityFilter): boolean {
return (!filter.from || date >= filter.from) && (!filter.to || date <= filter.to);
}
function sumOf(values: ReadonlyArray<number | null>): TourActivitySum {
const known = values.filter((value): value is number => value !== null && Number.isFinite(value));
return {
value: known.length ? known.reduce((total, value) => total + value, 0) : null,
known: known.length,
of: values.length,
};
}
function totalsOf(entries: readonly TourActivityStatsEntry[]): TourActivityTotals {
const withHeights = entries.filter((entry) => entry.tour_type !== 'kayak');
return {
count: entries.length,
distanceKm: sumOf(entries.map((entry) => entry.distance_km)),
ascentM: sumOf(withHeights.map((entry) => entry.ascent_m)),
descentM: sumOf(withHeights.map((entry) => entry.descent_m)),
byBasis: {
plan: entries.filter((entry) => entry.basis === 'plan').length,
track: entries.filter((entry) => entry.basis === 'track').length,
},
};
}
/** 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(
(entry) => inPeriod(entry.date, filter) && (!filter.type || entry.tour_type === filter.type),
);
const done = matching.filter((entry) => isTourActivityDone(entry, filter.today));
const counted = done.filter((entry) => !entry.excluded);
const byType = new Map<TourTypeKey, TourActivityStatsEntry[]>();
for (const entry of counted) byType.set(entry.tour_type, [...(byType.get(entry.tour_type) ?? []), entry]);
return {
totals: totalsOf(counted),
types: [...byType.entries()]
.map(([type, ofType]) => ({ type, ...totalsOf(ofType) }))
.sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)),
done: [...done].sort((a, b) => b.date.localeCompare(a.date) || a.name.localeCompare(b.name)),
upcoming: matching.filter((entry) => !entry.excluded && !isTourActivityDone(entry, filter.today)).length,
};
}
+12
View File
@@ -73,6 +73,18 @@ Your Atlas statistics panel shows:
- **Travel streak** — number of consecutive years in which you have taken at least one trip.
- **Trips this year** — number of trips in the current calendar year.
## Tour activities
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.
- **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".
- **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.
## Visual effect
The desktop glass panel at the bottom of the map uses a liquid-glass visual effect — a dynamic inner glow and border highlight that follows your cursor across the panel.
+3 -1
View File
@@ -233,7 +233,7 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T
### Tours _(Tours addon required)_
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, and a saved change taken back the same way. `take_over_tour`, `create_city_walk`, `save_tour_change`, `undo_tour_change`, `assign_tour_track` and `unassign_tour_track` write; every other tool here only reads. Tours someone is still drawing in the planner are not saved yet and do not show here.
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track` and `set_tour_activity_excluded` write; every other tool here only reads. Tours someone is still drawing in the planner are not saved yet and do not show here.
| Tool | Description | Scope |
|---|---|---|
@@ -246,6 +246,8 @@ 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` |
| `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` |
| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk` | `trips:read` |