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

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

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

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
This commit is contained in:
Maurice
2026-10-20 13:43:12 +02:00
parent ae79ab8dad
commit bde2ad2ca8
76 changed files with 4717 additions and 16 deletions
@@ -0,0 +1,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,
};
}