feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)

Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
This commit is contained in:
Maurice
2026-10-20 13:06:17 +02:00
parent a8be1c5b45
commit 6a0c54cb2f
51 changed files with 1699 additions and 56 deletions
@@ -323,6 +323,42 @@ describe('TourDetailDialog', () => {
expect(screen.getByTestId('tour-detail-track-of')).toHaveTextContent('Actual track of Lake loop')
})
it('ACT-02: an actual track stands beside the plan the head shows, and only the map detail offers to compare it there', async () => {
const activity = {
id: 4,
assignment_id: null,
day_id: null,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx' as const, file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 3,
elevation_gain_m: null,
elevation_loss_m: null,
started_at: null,
ended_at: null,
elapsed_minutes: null,
point_count: 2,
},
geometry: [
[48, 11],
[48, 11.03],
] as [number, number][],
}
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour, waypoints: [], activities: [activity] })
const { unmount } = render(<TourDetailDialog {...defaultProps} desktopNonModal />)
const distance = await screen.findByTestId('tour-activity-compare-distance')
// The plan as the head measures it from the line, not the list's stale 999 km.
expect(distance).toHaveTextContent('2.2 km')
expect(distance).toHaveTextContent('3 km')
expect(screen.getByRole('button', { name: /Compare on map/ })).toBeInTheDocument()
unmount()
render(<TourDetailDialog {...defaultProps} />)
await screen.findByTestId('tour-activity-compare-distance')
expect(screen.queryByRole('button', { name: /Compare on map/ })).not.toBeInTheDocument()
})
it('shows the grade on the tile and no second difficulty scale', () => {
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
@@ -50,6 +50,7 @@ import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/tourActivityCompare';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
@@ -543,6 +544,21 @@ export default function TourDetailDialog({
const times = tourPlannedTimes(tour);
const timeFields = tourTimeFields(times, t, tour);
// The plan as the line measures it now: the head says it, and an actual track is compared with it (ACT-02).
const planDistanceKm = analysis?.distanceKm ?? tour.distance;
const planGain = analysis ? analysis.gain : tour.elevation_gain;
const planLoss = analysis ? analysis.loss : tour.elevation_loss;
const plan = useMemo<PlanValues>(
() => ({
distanceKm: planDistanceKm,
gain: planGain,
loss: planLoss,
minutes: times.plannedTotalMinutes,
minutesAtLeast: times.atLeast,
line: analysis?.routeCoordinates ?? null,
}),
[analysis, planDistanceKm, planGain, planLoss, times.atLeast, times.plannedTotalMinutes]
);
const header = (
<>
@@ -573,11 +589,7 @@ export default function TourDetailDialog({
trackColor={trackColor}
timeFields={timeFields}
times={times}
metrics={{
distanceKm: analysis?.distanceKm ?? tour.distance,
gain: analysis ? analysis.gain : tour.elevation_gain,
loss: analysis ? analysis.loss : tour.elevation_loss,
}}
metrics={{ distanceKm: plan.distanceKm, gain: plan.gain, loss: plan.loss }}
/>
</div>
</>
@@ -734,6 +746,9 @@ export default function TourDetailDialog({
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor={trackColor}
mapCompare={desktopNonModal}
onToursChanged={onToursChanged}
/>
)}
@@ -0,0 +1,27 @@
// FE-ACT-COMPARE-LEGEND-001
// ACT-02 over the plan map, rendered without the translation provider so
// every text reads as its key: the legend names both lines, and closing it
// ends the comparison.
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import ActivityCompareLegend from './ActivityCompareLegend';
beforeEach(() => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
});
describe('ActivityCompareLegend (ACT-02)', () => {
it('FE-ACT-COMPARE-LEGEND-001: names the plan and the actual track, and ends the comparison when closed', () => {
const legend = { planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' };
render(<ActivityCompareLegend legend={legend} />);
const card = screen.getByTestId('activity-compare-legend');
expect(card).toHaveTextContent('tours.activity.compare.plan');
expect(card).toHaveTextContent('Ridge');
expect(card).toHaveTextContent('tours.activity.badge');
expect(card).toHaveTextContent('Watch export');
fireEvent.click(screen.getByRole('button', { name: 'tours.activity.compare.hide' }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
});
@@ -0,0 +1,74 @@
import { GitCompareArrows, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { Eyebrow } from '../../Planner/bookings/bookingParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import type { ActivityCompareLegend as Legend } from './useActivityCompareMap';
/** One line of the legend: how it is drawn, what it is, and whose it is. */
function LegendRow({ swatch, label, name }: { swatch: ReactNode; label: string; name: string }) {
return (
<li className="flex min-w-0 items-center gap-2">
<span className="grid h-6 w-8 flex-none place-items-center rounded-full bg-surface-card shadow-sm ring-1 ring-inset ring-edge-faint">
{swatch}
</span>
<span className="flex min-w-0 flex-col">
<Eyebrow>{label}</Eyebrow>
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>
{name}
</span>
</span>
</li>
);
}
/**
* ACT-02 over the plan map: what its two lines are while a tour is compared
* with its actual track, in the frosted look of the map's cards. Closing it
* ends the comparison; it changes nothing else.
*/
export default function ActivityCompareLegend({ legend }: { legend: Legend }) {
const { t } = useTranslation();
const close = t('tours.activity.compare.hide');
return (
<aside
data-testid="activity-compare-legend"
aria-label={t('tours.activity.compare.title')}
className="pointer-events-auto w-[240px] rounded-2xl p-3"
style={MAP_CARD_GLASS}
>
<div className="mb-2 flex items-center gap-1.5">
<GitCompareArrows size={13} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.activity.compare.title')}
</span>
<Tooltip label={close}>
<button
type="button"
onClick={() => useTourActivityCompareStore.getState().clear()}
aria-label={close}
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<X size={13} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
<LegendRow
swatch={<LineSwatch color={legend.planColor} />}
label={t('tours.activity.compare.plan')}
name={legend.planName}
/>
<LegendRow
swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />}
label={t('tours.activity.badge')}
name={legend.trackName}
/>
</ul>
</aside>
);
}
@@ -0,0 +1,142 @@
// FE-ACT-COMPARE-VIEW-001 to FE-ACT-COMPARE-VIEW-006
// ACT-02 in an actual track's card, rendered without the translation
// provider so every text reads as its key: plan and track side by side under
// their own names, the difference only where both are known, a missing value
// named as missing, the deviation from the planned line, and the map switch.
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourActivityCompare, { formatDifference } from './TourActivityCompare';
import type { PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 2,
gain: 120,
loss: null,
minutes: 180,
minutesAtLeast: false,
line: [
[47, 11],
[47.01, 11],
[47.02, 11],
],
};
function activity(overrides: Partial<TourActivity['metrics']> = {}): TourActivity {
return {
id: 4,
assignment_id: 11,
day_id: 1,
assigned_at: null,
track: { place_id: 20, name: 'Watch export', origin: 'gpx', file_name: 'morning.gpx', imported_at: null },
metrics: {
distance_km: 2.5,
elevation_gain_m: 120,
elevation_loss_m: 90,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T09:15:00Z',
elapsed_minutes: 195,
point_count: 3,
...overrides,
},
geometry: [
[47, 11, 600],
[47.012, 11.01, 650],
[47.02, 11, 700],
],
};
}
const row = (key: string) => screen.getByTestId(`tour-activity-compare-${key}`);
function show(props: Partial<Parameters<typeof TourActivityCompare>[0]> = {}) {
return render(
<TourActivityCompare activity={activity()} plan={plan} planColor="plan-paint" unit="metric" {...props} />
);
}
describe('TourActivityCompare (ACT-02)', () => {
it('FE-ACT-COMPARE-VIEW-001: names plan, actual and difference, and sets each measure of both side by side', () => {
show();
const table = screen.getByTestId('tour-activity-compare');
expect(within(table).getByText('tours.activity.compare.plan')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.badge')).toBeInTheDocument();
expect(within(table).getByText('tours.activity.compare.difference')).toBeInTheDocument();
expect(row('distance')).toHaveTextContent('2 km');
expect(row('distance')).toHaveTextContent('2.5 km');
expect(row('distance')).toHaveTextContent('+500 m');
expect(row('ascent')).toHaveTextContent('±0 m');
expect(row('time')).toHaveTextContent('3 h');
expect(row('time')).toHaveTextContent('3 h 15 min');
expect(row('time')).toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-002: a value a side does not carry is named missing, and nothing is set between them', () => {
show({ activity: activity({ elapsed_minutes: null }), plan: { ...plan, gain: null } });
expect(row('ascent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('descent')).toHaveTextContent('tours.activity.compare.notInPlan');
expect(row('time')).toHaveTextContent('tours.activity.noTime');
expect(within(row('time')).queryByText(/min$/)).toBeNull();
show({ activity: activity({ elevation_loss_m: null }), plan: { ...plan, loss: 80 } });
expect(screen.getAllByTestId('tour-activity-compare-descent')[1]).toHaveTextContent(
'tours.activity.compare.notInTrack'
);
});
it('FE-ACT-COMPARE-VIEW-003: a planned total that is only a floor reads "at least" and is not subtracted from', () => {
show({ plan: { ...plan, minutesAtLeast: true } });
expect(row('time')).toHaveTextContent('≥ 3 h');
expect(row('time')).not.toHaveTextContent('+15 min');
});
it('FE-ACT-COMPARE-VIEW-004: measures how far the track left the planned line, or says there is no line', () => {
const { unmount } = show();
expect(screen.getByText('tours.activity.compare.farthest')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlan')).toBeInTheDocument();
expect(screen.getByText('tours.activity.compare.offPlanValue')).toBeInTheDocument();
unmount();
show({ plan: { ...plan, line: null } });
expect(screen.queryByText('tours.activity.compare.farthest')).toBeNull();
expect(screen.getByText('tours.activity.compare.noLine')).toBeInTheDocument();
});
it('FE-ACT-COMPARE-VIEW-005: the map switch draws and ends the comparison, and says which it is', () => {
const onToggle = vi.fn();
const { rerender } = show({ map: { shown: false, onToggle } });
const button = screen.getByRole('button', { name: /tours.activity.compare.show/ });
expect(button).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(
<TourActivityCompare
activity={activity()}
plan={plan}
planColor="plan-paint"
unit="metric"
map={{ shown: true, onToggle }}
/>
);
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-ACT-COMPARE-VIEW-006: no map switch without a plan map beside the detail, or without a line to draw', () => {
const { unmount } = show();
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
unmount();
show({ activity: { ...activity(), geometry: [] }, map: { shown: false, onToggle: vi.fn() } });
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
});
describe('formatDifference (ACT-02)', () => {
it('signs a difference, calls one that rounds to nothing even, and keeps the unit shown', () => {
expect(formatDifference('distance', -1.25, 'metric')).toBe('-1.3 km');
expect(formatDifference('descent', 0.2, 'metric')).toBe('±0 m');
expect(formatDifference('ascent', 30, 'imperial')).toBe('+98 ft');
expect(formatDifference('time', -45, 'metric')).toBe('-45 min');
});
});
@@ -0,0 +1,231 @@
import type { TourActivity } from '@trek/shared';
import { Equal, Map as MapIcon, TrendingDown, TrendingUp } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { BOX, Eyebrow } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourFactFields, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { ACTUAL_TRACK_COLOR, LineSwatch } from './activityCompareParts';
import {
compareRows,
OFF_PLAN_M,
planDeviation,
type CompareKey,
type CompareRow,
type PlanValues,
} from './tourActivityCompare';
type Translate = (key: string, params?: Record<string, unknown>) => string;
const ROW_LABELS: Record<CompareKey, string> = {
distance: 'tours.detail.distance',
ascent: 'tours.detail.ascent',
descent: 'tours.detail.descent',
time: 'tours.activity.compare.time',
};
function formatValue(key: CompareKey, value: number, unit: DistanceUnit): string {
if (key === 'distance') return formatDistance(value, unit);
if (key === 'time') return formatPlannedTourDuration(value);
return formatElevation(value, unit);
}
/** A difference as it reads: signed, or "±" where it rounds to nothing in the unit shown. */
export function formatDifference(key: CompareKey, difference: number, unit: DistanceUnit): string {
const size = formatValue(key, Math.abs(difference), unit);
if (size === formatValue(key, 0, unit)) return `±${size}`;
return `${difference > 0 ? '+' : '-'}${size}`;
}
/** One value of one side: the number, or a word saying that side does not carry it. */
function ValueCell({
text,
missing,
hint,
strong = false,
}: {
text: string;
missing: boolean;
hint?: string;
strong?: boolean;
}) {
const look = missing
? 'italic text-content-faint'
: strong
? 'font-semibold text-content'
: 'font-medium text-content-secondary';
return (
<td className="px-2 py-1.5 text-end tabular-nums">
<Tooltip label={hint ?? ''} disabled={!hint}>
<span className={`inline-flex ${look}`}>{text}</span>
</Tooltip>
</td>
);
}
/** The plan's value; a total that leaves a stay out reads "at least", as the head says it. */
function planText(row: CompareRow, plan: PlanValues, unit: DistanceUnit, t: Translate): string {
if (row.plan === null) return t('tours.activity.compare.notInPlan');
const text = formatValue(row.key, row.plan, unit);
return row.key === 'time' && plan.minutesAtLeast ? `≥ ${text}` : text;
}
/** The track's value; a time is said to be measured, or the time stamps to be missing. */
function actualCell(row: CompareRow, unit: DistanceUnit, t: Translate): { text: string; hint?: string } {
if (row.key === 'time') {
return row.actual === null
? { text: t('tours.activity.noTime'), hint: t('tours.activity.noTimeHint') }
: { text: formatValue('time', row.actual, unit), hint: t('tours.activity.timeHint') };
}
return {
text: row.actual === null ? t('tours.activity.compare.notInTrack') : formatValue(row.key, row.actual, unit),
};
}
/** The difference as a small badge whose arrow says which way; empty where the two cannot be compared. */
function DifferenceCell({ row, unit }: { row: CompareRow; unit: DistanceUnit }) {
if (row.difference === null) return <td className="px-2 py-1.5" />;
const text = formatDifference(row.key, row.difference, unit);
const Icon = text.startsWith('±') ? Equal : row.difference > 0 ? TrendingUp : TrendingDown;
return (
<td className="px-2 py-1.5 text-end">
<SoftPill
className="tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
>
{text}
</SoftPill>
</td>
);
}
/** A column head: its name with the line it is drawn as on the map. */
function ColumnHead({ label, swatch }: { label: string; swatch?: ReactNode }) {
return (
<th scope="col" className="px-2 py-1.5 text-end font-normal">
<span className="inline-flex items-center gap-1.5">
{swatch}
<Eyebrow>{label}</Eyebrow>
</span>
</th>
);
}
/** The four measures of plan and track, one row each, with what lies between them. */
function CompareTable({
rows,
plan,
planColor,
unit,
}: {
rows: readonly CompareRow[];
plan: PlanValues;
planColor: string;
unit: DistanceUnit;
}) {
const { t } = useTranslation();
return (
<div className={`${BOX} overflow-hidden`}>
<table data-testid="tour-activity-compare" className="w-full border-collapse" style={fs(12, 'body')}>
<thead className="bg-surface-secondary">
<tr>
<td />
<ColumnHead label={t('tours.activity.compare.plan')} swatch={<LineSwatch color={planColor} />} />
<ColumnHead label={t('tours.activity.badge')} swatch={<LineSwatch color={ACTUAL_TRACK_COLOR} dotted />} />
<ColumnHead label={t('tours.activity.compare.difference')} />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const actual = actualCell(row, unit, t);
return (
<tr key={row.key} data-testid={`tour-activity-compare-${row.key}`} className="border-t border-edge-faint">
<th scope="row" className="px-2.5 py-1.5 text-start font-medium text-content-muted">
{t(ROW_LABELS[row.key])}
</th>
<ValueCell text={planText(row, plan, unit, t)} missing={row.plan === null} />
<ValueCell text={actual.text} hint={actual.hint} missing={row.actual === null} strong />
<DifferenceCell row={row} unit={unit} />
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** How far the track left the planned line, or that there is no line to measure against. */
function deviationFields(
activity: TourActivity,
plan: PlanValues,
unit: DistanceUnit,
t: Translate
): TourMetricField[] {
const deviation = planDeviation(activity.geometry, plan.line);
const offLabel = t('tours.activity.compare.offPlan');
if (!deviation) return [{ label: offLabel, value: t('tours.activity.compare.noLine') }];
const offInfo = {
label: offLabel,
description: t('tours.activity.compare.offPlanHint', { distance: formatDistance(OFF_PLAN_M / 1000, unit) }),
};
return [
{ label: t('tours.activity.compare.farthest'), value: formatDistance(deviation.farthestM / 1000, unit) },
{
label: offLabel,
value: t('tours.activity.compare.offPlanValue', {
distance: formatDistance(deviation.offPlanKm, unit),
percent: Math.round(deviation.offPlanShare * 100),
}),
info: offInfo,
},
];
}
interface TourActivityCompareProps {
activity: TourActivity;
plan: PlanValues;
/** The plan's track colour, as the map draws it. */
planColor: string;
unit: DistanceUnit;
/** The switch for the plan map; left out where no plan map is beside the detail (the phone). */
map?: { shown: boolean; onToggle: () => void };
}
/**
* ACT-02 in an actual track's card: its values beside the plan's, each side
* named and drawn as it is on the map, the difference only where both are
* known, and how far the track strayed from the planned line. It reads both
* and changes neither; the map switch only draws.
*/
export default function TourActivityCompare({ activity, plan, planColor, unit, map }: TourActivityCompareProps) {
const { t } = useTranslation();
const rows = useMemo(() => compareRows(plan, activity.metrics), [activity.metrics, plan]);
const deviation = useMemo(() => deviationFields(activity, plan, unit, t), [activity, plan, unit, t]);
const mapLabel = t(map?.shown ? 'tours.activity.compare.hide' : 'tours.activity.compare.show');
return (
<section aria-label={t('tours.activity.compare.title')} className="flex flex-col gap-2">
<div className="flex min-h-[28px] items-center gap-2">
<Eyebrow>{t('tours.activity.compare.title')}</Eyebrow>
{map && activity.geometry.length >= 2 && (
<span className="ms-auto inline-flex">
<Tooltip label={t('tours.activity.compare.showHint')} disabled={map.shown}>
<span className="inline-flex">
<CardAction icon={<MapIcon size={12} strokeWidth={2.2} />} onClick={map.onToggle} pressed={map.shown}>
{mapLabel}
</CardAction>
</span>
</Tooltip>
</span>
)}
</div>
<CompareTable rows={rows} plan={plan} planColor={planColor} unit={unit} />
<TourFactFields fields={deviation} columns={deviation.length === 2 ? 2 : undefined} />
</section>
);
}
@@ -1,13 +1,16 @@
// FE-ACT-SECTION-001 to FE-ACT-SECTION-006
// FE-ACT-SECTION-001 to FE-ACT-SECTION-007
// ACT-01 in the tour's detail, rendered without the translation provider so
// every text reads as its key: the actual tracks with their own values marked
// as such, a time only where the track measured one, and the actions only for
// someone who may change the tour.
import { fireEvent, render, screen } from '@testing-library/react';
// as such beside the plan's (ACT-02), a time only where the track measured
// one, the actions only for someone who may change the tour, and the map
// comparison only where a plan map is beside the detail.
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Day } from '../../../types';
import TourActivitySection from './TourActivitySection';
import type { PlanValues } from './tourActivityCompare';
import type { TourActivities } from './useTourActivities';
const hook = vi.hoisted(() => ({ state: null as unknown }));
@@ -60,30 +63,43 @@ function state(overrides: Partial<TourActivities> = {}): TourActivities {
} as TourActivities;
}
const show = (canEdit = true, item: TourListItem = tour) =>
render(<TourActivitySection tripId={1} tour={item} days={days} assignments={assignments} canEdit={canEdit} />);
const plan: PlanValues = { distanceKm: 0.1, gain: 80, loss: 0, minutes: 180, minutesAtLeast: false, line: null };
const show = (canEdit = true, item: TourListItem = tour, mapCompare = false) =>
render(
<TourActivitySection
tripId={1}
tour={item}
days={days}
assignments={assignments}
canEdit={canEdit}
plan={plan}
planColor="plan-paint"
mapCompare={mapCompare}
/>
);
beforeEach(() => {
hook.state = state();
useTourActivityCompareStore.getState().clear();
});
describe('TourActivitySection (ACT-01)', () => {
it('FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values and its profile', () => {
it("FE-ACT-SECTION-001: shows an actual track with its day, its file, its own values beside the plan's, and its profile", () => {
show();
expect(screen.getByText('tours.activity.label')).toBeInTheDocument();
expect(screen.getByText('Watch export')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('dayplan.dayN');
expect(screen.getByText('morning.gpx')).toBeInTheDocument();
expect(screen.getByText('tours.activity.badge')).toBeInTheDocument();
expect(screen.getByText('tours.activity.time')).toBeInTheDocument();
expect(screen.getByText('3 h 15 min')).toBeInTheDocument();
expect(screen.getByText('tours.detail.ascent')).toBeInTheDocument();
expect(screen.getAllByText('tours.activity.badge').length).toBeGreaterThan(0);
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('3 h 15 min');
expect(screen.getByTestId('tour-activity-compare-ascent')).toHaveTextContent('+20 m');
});
it('FE-ACT-SECTION-002: a track without times measures no time, and a track without a day says so', () => {
hook.state = state({ activities: [activity({ elapsed_minutes: null, started_at: null, ended_at: null }, null)] });
show();
expect(screen.getByText('tours.activity.noTime')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-compare-time')).toHaveTextContent('tours.activity.noTime');
expect(screen.getByTestId('tour-activity-day')).toHaveTextContent('tours.activity.noDay');
expect(screen.queryByText('tours.activity.recordedOn')).toBeNull();
});
@@ -146,4 +162,20 @@ describe('TourActivitySection (ACT-01)', () => {
show(false);
expect(screen.getByText('tours.activity.unavailable')).toBeInTheDocument();
});
it('FE-ACT-SECTION-007: beside a plan map a track is compared there and back, and on the phone it is not offered', () => {
const { unmount } = show(false, tour, true);
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.show/ }));
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
expect(screen.getByRole('button', { name: /tours.activity.compare.hide/ })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /tours.activity.compare.hide/ }));
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
show(false);
expect(screen.queryByRole('button', { name: /tours.activity.compare.show/ })).toBeNull();
});
});
@@ -11,24 +11,21 @@ import { DialogSection, fs } from '../../shared/DialogShell';
import ElevationProfile from '../../shared/ElevationProfile';
import { Tooltip } from '../../shared/Tooltip';
import { dayMenuLabel } from '../tourDays';
import { CardAction, TourMetricFields, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { CardAction, TourNotice } from '../tourParts';
import TourActivityCompare from './TourActivityCompare';
import TourActivityDialog from './TourActivityDialog';
import { ACTUAL_TRACK_COLOR } from './activityCompareParts';
import type { PlanValues } from './tourActivityCompare';
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
import { useActivityComparison, type ActivityComparisonState } from './useActivityComparison';
import { useTourActivities, type TourActivities } from './useTourActivities';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The line and the profile of what was recorded take the colour of done, apart from the plan's track colour. */
const RECORDED_COLOR = 'var(--success)';
/** The recorded time as a field: measured from the track's times, or said to be missing, never estimated. */
function timeField(activity: TourActivity, t: Translate): TourMetricField {
const minutes = activity.metrics.elapsed_minutes;
const label = t('tours.activity.time');
return minutes === null
? { label, value: t('tours.activity.noTime'), info: { label, description: t('tours.activity.noTimeHint') } }
: { label, value: formatPlannedTourDuration(minutes), info: { label, description: t('tours.activity.timeHint') } };
/** What a card compares its track with: the plan's values and colour, and the map switch where there is a plan map. */
interface PlanSide {
plan: PlanValues;
planColor: string;
/** Null on the phone, whose detail has no plan map beside it. */
comparison: ActivityComparisonState | null;
}
/** The tile of a recording: footprints, with a small dot at its foot that says it was recorded. */
@@ -76,19 +73,26 @@ function ActivityBadges({ activity, execution }: { activity: TourActivity; execu
);
}
/** One recording: who it is, its values marked as recorded, its profile, and what the user can do with it. */
/**
* One recording: who it is, its values beside the plan's (ACT-02), its
* profile, and what the user can do with it.
*/
function ActivityCard({
activity,
execution,
state,
side,
}: {
activity: TourActivity;
execution: TourExecution;
state: TourActivities;
side: PlanSide;
}) {
const { t } = useTranslation();
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const analysis = useMemo(() => analysisOfLine(activity.geometry), [activity.geometry]);
const trackPlaceId = activity.track.place_id;
const { comparison } = side;
return (
<article data-testid="tour-activity" className={`${BOX} overflow-hidden`}>
<header className="flex items-start gap-2.5 px-3 py-2.5">
@@ -108,11 +112,21 @@ function ActivityCard({
</Tooltip>
</header>
<div className="flex flex-col gap-2 border-t border-edge-faint p-2.5">
<TourMetricFields analysis={analysis} unit={unit} extra={[timeField(activity, t)]} />
<TourActivityCompare
activity={activity}
plan={side.plan}
planColor={side.planColor}
unit={unit}
map={
comparison
? { shown: comparison.comparedTrack === trackPlaceId, onToggle: () => comparison.toggle(trackPlaceId) }
: undefined
}
/>
{analysis && analysis.distanceIndexedProfileSamples.length >= 2 && (
<ElevationProfile
samples={analysis.distanceIndexedProfileSamples}
color={RECORDED_COLOR}
color={ACTUAL_TRACK_COLOR}
gradientId={`tour-activity-${activity.id}`}
/>
)}
@@ -157,7 +171,15 @@ function NoActivity() {
}
/** What the section holds: the tracks, the empty place for one, or word that the tour's tracks could not be read. */
function ActivityList({ state, executions }: { state: TourActivities; executions: readonly TourExecution[] }) {
function ActivityList({
state,
executions,
side,
}: {
state: TourActivities;
executions: readonly TourExecution[];
side: PlanSide;
}) {
const { t } = useTranslation();
const activities = state.activities ?? [];
if (state.unavailable) {
@@ -176,6 +198,7 @@ function ActivityList({ state, executions }: { state: TourActivities; executions
activity={activity}
execution={executionOf(executions, activity.assignment_id)}
state={state}
side={side}
/>
))}
</div>
@@ -188,6 +211,12 @@ interface TourActivitySectionProps {
days: Day[];
assignments: AssignmentsMap;
canEdit: boolean;
/** ACT-02: what the plan says, for each track's values to stand beside. */
plan: PlanValues;
/** The plan's track colour, as the map and the table draw it. */
planColor: string;
/** Offers to compare a track on the plan map; only where one is beside the detail. */
mapCompare?: boolean;
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
@@ -195,8 +224,10 @@ interface TourActivitySectionProps {
* ACT-01 in the tour's detail: the tracks the tour was actually done with,
* each with the values its own recording measures and its profile, apart from
* the planned values above; and for someone who may change the tour, the way
* to assign one, change it or take it off. A tour that is itself the recording
* of another shows none: it says so in its head instead.
* to assign one, change it or take it off. Each track's values stand beside
* the plan's, and one track at a time can be compared with the plan on the map
* (ACT-02). A tour that is itself the recording of another shows none: it says
* so in its head instead.
*/
export default function TourActivitySection({
tripId,
@@ -204,12 +235,17 @@ export default function TourActivitySection({
days,
assignments,
canEdit,
plan,
planColor,
mapCompare = false,
onToursChanged,
}: TourActivitySectionProps) {
const { t } = useTranslation();
const executions = useMemo(() => executionsOf(tour.place_id, days, assignments), [assignments, days, tour.place_id]);
const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged });
const { activities, unavailable } = state;
const comparison = useActivityComparison(tour.place_id, activities);
const side: PlanSide = { plan, planColor, comparison: mapCompare ? comparison : null };
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) {
return null;
}
@@ -233,7 +269,7 @@ export default function TourActivitySection({
)
}
>
<ActivityList state={state} executions={executions} />
<ActivityList state={state} executions={executions} side={side} />
{state.assigning && (
<TourActivityDialog
tripId={tripId}
@@ -0,0 +1,28 @@
/**
* ACT-02: how plan and actual track look wherever they are named side by
* side, so the detail's table and the map's legend show the same two lines:
* the plan solid in its track colour, the actual track dotted in the colour
* of done. Dotted as well as coloured, so the two stay apart for someone who
* cannot tell the hues apart, and on a screenshot in black and white.
*/
/** The colour of what was recorded, as the actual track's card and profile already use it. */
export const ACTUAL_TRACK_COLOR = 'var(--success)';
/** A short stretch of line: solid for the plan, dotted for the actual track. */
export function LineSwatch({ color, dotted = false }: { color: string; dotted?: boolean }) {
return (
<svg width="22" height="8" viewBox="0 0 22 8" aria-hidden="true" className="flex-none overflow-visible">
<line
x1="2"
y1="4"
x2="20"
y2="4"
strokeWidth={dotted ? 3.2 : 3.5}
strokeLinecap="round"
strokeDasharray={dotted ? '0.1 5.4' : undefined}
style={{ stroke: color }}
/>
</svg>
);
}
@@ -0,0 +1,108 @@
// FE-ACT-COMPARE-MODEL-001 to FE-ACT-COMPARE-MODEL-006
// ACT-02, the pure half of comparing a plan with its actual track: the values
// side by side, a difference only where both are known and comparable, and
// how far the recorded line strayed from the planned one.
import type { TourActivityMetrics } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { compareRows, MEASURED_POINTS, OFF_PLAN_M, planDeviation, type PlanValues } from './tourActivityCompare';
const plan: PlanValues = {
distanceKm: 8,
gain: 600,
loss: 550,
minutes: 240,
minutesAtLeast: false,
line: null,
};
const metrics: TourActivityMetrics = {
distance_km: 8.6,
elevation_gain_m: 640,
elevation_loss_m: null,
started_at: '2026-07-02T06:00:00Z',
ended_at: '2026-07-02T10:30:00Z',
elapsed_minutes: 270,
point_count: 400,
};
// A planned line running north along 11° E for about 2.2 km.
const planned = [
[47, 11],
[47.01, 11],
[47.02, 11],
];
describe('compareRows (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-001: names both values of every measure and what lies between them', () => {
const rows = compareRows(plan, metrics);
expect(rows.map((row) => row.key)).toEqual(['distance', 'ascent', 'descent', 'time']);
expect(rows[0]).toEqual({ key: 'distance', plan: 8, actual: 8.6, difference: expect.closeTo(0.6, 6) });
expect(rows[1]).toEqual({ key: 'ascent', plan: 600, actual: 640, difference: 40 });
expect(rows[3]).toEqual({ key: 'time', plan: 240, actual: 270, difference: 30 });
});
it('FE-ACT-COMPARE-MODEL-002: a value one side does not carry stays missing, and so does the difference', () => {
const rows = compareRows({ ...plan, gain: null, minutes: null }, metrics);
expect(rows[1]).toEqual({ key: 'ascent', plan: null, actual: 640, difference: null });
expect(rows[2]).toEqual({ key: 'descent', plan: 550, actual: null, difference: null });
expect(rows[3]).toEqual({ key: 'time', plan: null, actual: 270, difference: null });
});
it('FE-ACT-COMPARE-MODEL-003: a planned total that is only a floor is shown but never subtracted from', () => {
const time = compareRows({ ...plan, minutesAtLeast: true }, metrics)[3];
expect(time).toEqual({ key: 'time', plan: 240, actual: 270, difference: null });
});
});
describe('planDeviation (ACT-02)', () => {
it('FE-ACT-COMPARE-MODEL-004: a track along the plan stays on it', () => {
const deviation = planDeviation(
[
[47, 11.0001],
[47.01, 11.0001],
[47.02, 11.0001],
],
planned
);
expect(deviation?.farthestM).toBeLessThan(OFF_PLAN_M);
expect(deviation?.offPlanKm).toBe(0);
expect(deviation?.offPlanShare).toBe(0);
});
it('FE-ACT-COMPARE-MODEL-005: a detour away from the plan is measured by its farthest point and its length', () => {
// Out along the plan, then about 760 m east and back to the plan's end.
const deviation = planDeviation(
[
[47, 11],
[47.01, 11],
[47.012, 11.01],
[47.018, 11.01],
[47.02, 11],
],
planned
);
expect(deviation?.farthestM).toBeGreaterThan(740);
expect(deviation?.farthestM).toBeLessThan(780);
// Only the leg between the two eastern points has both ends off the plan: about 670 m.
expect(deviation?.offPlanKm).toBeGreaterThan(0.6);
expect(deviation?.offPlanKm).toBeLessThan(0.7);
// Of about 3.4 km recorded in all.
expect(deviation?.offPlanShare).toBeGreaterThan(0.15);
expect(deviation?.offPlanShare).toBeLessThan(0.25);
});
it('FE-ACT-COMPARE-MODEL-006: nothing without two points on either line, and a long track measured in samples to its end', () => {
expect(planDeviation([[47, 11]], planned)).toBeNull();
expect(planDeviation(planned, null)).toBeNull();
expect(planDeviation(planned, [[47, 11]])).toBeNull();
// Twice as many points as are measured along the plan, then on past its end; the last point is kept.
const count = MEASURED_POINTS * 2;
const long = Array.from({ length: count }, (_, index) => [47 + (0.02 * index) / count, 11]);
long.push([47.03, 11], [47.04, 11]);
const deviation = planDeviation(long, planned);
expect(deviation?.farthestM).toBeGreaterThan(2200);
expect(deviation?.offPlanKm).toBeGreaterThan(1.1);
expect(deviation?.offPlanKm).toBeLessThan(1.12);
});
});
@@ -0,0 +1,104 @@
import { distancesToLine, haversineMetres, type TourActivityMetrics } from '@trek/shared';
/**
* ACT-02, the pure half of comparing a tour's plan with a track it was
* actually done with: the values both carry side by side, the difference only
* where both are known and mean the same thing, and how far the recorded line
* strayed from the planned one. Nothing is estimated: a value one side does
* not carry stays missing and is said to be.
*/
type Line = readonly (readonly number[])[];
/** What the plan says, as the tour's detail shows it in its head. */
export interface PlanValues {
distanceKm: number | null;
gain: number | null;
loss: number | null;
/** The planned total, breaks included, as a recording's time includes its pauses; null without one. */
minutes: number | null;
/** The total leaves out a stay nobody entered yet (CW-02): a floor, not a value to subtract from. */
minutesAtLeast: boolean;
/** The planned line, `[lat, lng]` points; null while the tour has none. */
line: Line | null;
}
export type CompareKey = 'distance' | 'ascent' | 'descent' | 'time';
/** One measure of both: kilometres, metres or minutes, as the key says. */
export interface CompareRow {
key: CompareKey;
plan: number | null;
actual: number | null;
/** Actual less planned; null unless both are known and comparable. */
difference: number | null;
}
function row(key: CompareKey, plan: number | null, actual: number | null, comparable = true): CompareRow {
return { key, plan, actual, difference: comparable && plan !== null && actual !== null ? actual - plan : null };
}
/** The four measures both sides can carry, in the order the detail names them. */
export function compareRows(plan: PlanValues, metrics: TourActivityMetrics): CompareRow[] {
return [
row('distance', plan.distanceKm, metrics.distance_km),
row('ascent', plan.gain, metrics.elevation_gain_m),
row('descent', plan.loss, metrics.elevation_loss_m),
row('time', plan.minutes, metrics.elapsed_minutes, !plan.minutesAtLeast),
];
}
/**
* How far from the planned line a recorded point may lie and still count as
* on it: a phone's position wanders by tens of metres, and a planned line runs
* down the middle of a road the track walked beside.
*/
export const OFF_PLAN_M = 50;
/**
* At most this many recorded points are measured. A track logged every second
* holds tens of thousands, each measured against every leg of the plan; every
* n-th point keeps the measuring quick and the answer within a few metres.
*/
export const MEASURED_POINTS = 1500;
interface PlanDeviation {
/** The largest distance of a recorded point from the planned line, in metres. */
farthestM: number;
/** The recorded kilometres farther than OFF_PLAN_M from the plan. */
offPlanKm: number;
/** That share of the recorded distance, 0 to 1. */
offPlanShare: number;
}
/** Every n-th point, the last always among them, so the measured line still ends where the track did. */
function sampled(points: Line): Line {
if (points.length <= MEASURED_POINTS) return points;
const step = Math.ceil(points.length / MEASURED_POINTS);
const kept = points.filter((_, index) => index % step === 0);
return kept[kept.length - 1] === points[points.length - 1] ? kept : [...kept, points[points.length - 1]];
}
/**
* Where the recorded line left the planned one: its farthest point and the
* stretches away from it. A stretch counts as away when both its ends lie
* farther than OFF_PLAN_M. Null without two points on either line.
*/
export function planDeviation(recorded: Line, planned: Line | null): PlanDeviation | null {
if (!planned || planned.length < 2 || recorded.length < 2) return null;
const points = sampled(recorded);
const distances = distancesToLine(points, planned);
if (!distances) return null;
let total = 0;
let off = 0;
let farthestM = distances[0] ?? 0;
for (let index = 1; index < points.length; index += 1) {
const from = points[index - 1];
const to = points[index];
const step = haversineMetres(Number(from[0]), Number(from[1]), Number(to[0]), Number(to[1]));
total += step;
if ((distances[index - 1] ?? 0) > OFF_PLAN_M && (distances[index] ?? 0) > OFF_PLAN_M) off += step;
farthestM = Math.max(farthestM, distances[index] ?? 0);
}
return { farthestM, offPlanKm: off / 1000, offPlanShare: total > 0 ? off / total : 0 };
}
@@ -0,0 +1,117 @@
// FE-ACT-COMPARE-MAP-001 to FE-ACT-COMPARE-MAP-005
// ACT-02 on the plan map: while a tour is compared with its actual track, the
// track leaves the map as a tour of its own and comes back as one dotted line
// of the route, the plan stays on the map, and the legend names both; off,
// the map gets exactly what it would have had.
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../../tests/helpers/factories';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { compareMapLines, useActivityCompareMap } from './useActivityCompareMap';
const plan = buildPlace({ id: 7, name: 'Ridge', route_color: 'plan-paint' });
const track = buildPlace({
id: 20,
name: 'Watch export',
route_geometry: JSON.stringify([
[47, 11, 600],
[47.01, 11, 640],
]),
});
const cafe = buildPlace({ id: 3, name: 'Cafe' });
const places = [plan, track, cafe];
const dayRoute: [number, number][][] = [
[
[46, 10],
[46.1, 10],
],
[
[46.1, 10],
[46.2, 10],
],
];
const paint = { line: 'actual-paint', casing: 'casing-paint' };
const input = {
enabled: true,
places,
mapPlaces: [plan, track, cafe],
route: dayRoute,
routeWalking: [true],
selectedPlaceId: 7,
};
beforeEach(() => {
useTourActivityCompareStore.getState().clear();
});
describe('compareMapLines (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-001: lays the track into the route, dotted and in its own colour, and names both lines', () => {
const drawn = compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 20 }, paint);
expect(drawn.places.map((place) => place.id)).toEqual([7, 3]);
expect(drawn.route).toEqual([
...dayRoute,
[
[47, 11],
[47.01, 11],
],
]);
// The route's own flags run short of its lines; they are read by index.
expect(drawn.routeWalking).toEqual([true, false, true]);
expect(drawn.routeColors).toEqual([undefined, undefined, paint]);
expect(drawn.legend).toEqual({ planName: 'Ridge', planColor: 'plan-paint', trackName: 'Watch export' });
});
it('FE-ACT-COMPARE-MAP-002: a plan hidden from the map comes back, and a day without a route has the track alone', () => {
const drawn = compareMapLines(
{ ...input, mapPlaces: [cafe], route: null, routeWalking: null },
{ tourPlaceId: 7, trackPlaceId: 20 },
paint
);
expect(drawn.places.map((place) => place.id)).toEqual([3, 7]);
expect(drawn.route).toHaveLength(1);
expect(drawn.routeWalking).toEqual([true]);
expect(drawn.routeColors).toEqual([paint]);
});
it('FE-ACT-COMPARE-MAP-003: off, beside another tour, elsewhere on the map or without a line, the map gets what it had', () => {
const cases = [
compareMapLines(input, null, paint),
compareMapLines({ ...input, selectedPlaceId: 3 }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines({ ...input, enabled: false }, { tourPlaceId: 7, trackPlaceId: 20 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 3 }, paint),
compareMapLines(input, { tourPlaceId: 7, trackPlaceId: 99 }, paint),
];
for (const drawn of cases) {
expect(drawn.places).toBe(input.mapPlaces);
expect(drawn.route).toBe(dayRoute);
expect(drawn.routeWalking).toBe(input.routeWalking);
expect(drawn.routeColors).toBeUndefined();
expect(drawn.legend).toBeNull();
}
});
});
describe('useActivityCompareMap (ACT-02)', () => {
it('FE-ACT-COMPARE-MAP-004: follows the comparison the detail switches on and off', () => {
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.legend).toBeNull();
act(() => useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 }));
expect(result.current.legend?.trackName).toBe('Watch export');
expect(result.current.route).toHaveLength(3);
act(() => useTourActivityCompareStore.getState().clear());
expect(result.current.route).toBe(dayRoute);
});
it('FE-ACT-COMPARE-MAP-005: draws the track in the colour the theme gives done', () => {
document.documentElement.style.setProperty('--success', 'success-paint');
try {
useTourActivityCompareStore.getState().show({ tourPlaceId: 7, trackPlaceId: 20 });
const { result } = renderHook(() => useActivityCompareMap(input));
expect(result.current.routeColors?.[2]?.line).toBe('success-paint');
} finally {
document.documentElement.style.removeProperty('--success');
}
});
});
@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import type { Place } from '../../../types';
import { parseRenderableRouteGeometry } from '../../../utils/routeGeometry';
import { useMapPaint } from '../../Map/mapPaint';
import { resolveTrackColor } from '../../Map/trackColors';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/** The actual track's colour resolved for map paint; the map cannot read a CSS variable. */
const ACTUAL_PAINT = { line: '--success', casing: '--surface-card' } as const;
/** What the legend over the map names: the two lines, each as it is drawn. */
export interface ActivityCompareLegend {
planName: string;
planColor: string;
trackName: string;
}
interface CompareMapInput<P extends Place> {
/** False where the map shows something else (the road trip, the trip overview): nothing is compared there. */
enabled: boolean;
/** Every place of the trip, to find the plan and the recording in even when the map leaves them out. */
places: Place[];
/** The places the map draws. */
mapPlaces: P[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** The place whose detail is open: the comparison shows only beside its plan. */
selectedPlaceId: number | null;
}
/** The map's input as it is drawn: unchanged, or with the comparison laid in. */
interface CompareMapLines<P extends Place> {
places: (P | Place)[];
route: Line[] | null;
routeWalking: boolean[] | null;
/** One entry per line of `route`; left out, the route keeps its own colour throughout. */
routeColors: LineColor[] | undefined;
legend: ActivityCompareLegend | null;
}
/**
* The plan map's places and lines while the comparison is on, and the
* legend's names. Pure, for the hook below and its tests.
*
* The plan is the tour's own track, picked on the map and drawn in its track
* colour; it is put back on the map should the person have hidden it there.
* The recording leaves the map as a tour of its own and comes back as one more
* line of the route, dotted in the colour of done, so it is drawn once and
* never in the colour of a plan. Off, everything stays exactly as it came.
*/
export function compareMapLines<P extends Place>(
input: CompareMapInput<P>,
comparison: { tourPlaceId: number; trackPlaceId: number } | null,
paint: { line: string; casing: string }
): CompareMapLines<P> {
const unchanged: CompareMapLines<P> = {
places: input.mapPlaces,
route: input.route,
routeWalking: input.routeWalking,
routeColors: undefined,
legend: null,
};
if (!input.enabled || !comparison || comparison.tourPlaceId !== input.selectedPlaceId) return unchanged;
const plan = input.places.find((place) => place.id === comparison.tourPlaceId);
const track = input.places.find((place) => place.id === comparison.trackPlaceId);
const line = parseRenderableRouteGeometry(track?.route_geometry);
if (!plan || !track || !line || line.length < 2) return unchanged;
const route = input.route ?? [];
const drawn = input.mapPlaces.filter((place) => place.id !== track.id);
return {
places: drawn.some((place) => place.id === plan.id) ? drawn : [...drawn, plan],
route: [...route, line.map(([lat, lng]): [number, number] => [lat, lng])],
// Aligned by index: the route's own flags may run short of its lines.
routeWalking: [...route.map((_, index) => input.routeWalking?.[index] === true), true],
routeColors: [...route.map((): LineColor => undefined), paint],
legend: { planName: plan.name, planColor: resolveTrackColor(plan), trackName: track.name },
};
}
/** ACT-02 on the plan map: `compareMapLines` with the comparison of the store and the theme's colours. */
export function useActivityCompareMap<P extends Place>(input: CompareMapInput<P>) {
const comparison = useTourActivityCompareStore((state) => state.comparison);
const paint = useMapPaint(ACTUAL_PAINT);
const { enabled, places, mapPlaces, route, routeWalking, selectedPlaceId } = input;
return useMemo(
() => compareMapLines({ enabled, places, mapPlaces, route, routeWalking, selectedPlaceId }, comparison, paint),
[comparison, enabled, mapPlaces, paint, places, route, routeWalking, selectedPlaceId]
);
}
@@ -0,0 +1,61 @@
// FE-ACT-COMPARE-HOOK-001 to FE-ACT-COMPARE-HOOK-004
// ACT-02 from the tour's detail: one actual track at a time is compared on
// the map, and the comparison ends with the detail or with the track's link.
import { act, renderHook } from '@testing-library/react';
import type { TourActivity } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
import { useActivityComparison } from './useActivityComparison';
const linked = (trackPlaceId: number) => ({ id: trackPlaceId, track: { place_id: trackPlaceId } }) as TourActivity;
const activities = [linked(20), linked(21)];
beforeEach(() => {
useTourActivityCompareStore.getState().clear();
});
describe('useActivityComparison (ACT-02)', () => {
it('FE-ACT-COMPARE-HOOK-001: switches one track on, another instead, and off again', () => {
const { result } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
act(() => result.current.toggle(20));
expect(result.current.comparedTrack).toBe(20);
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 7, trackPlaceId: 20 });
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBe(21);
act(() => result.current.toggle(21));
expect(result.current.comparedTrack).toBeNull();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it("FE-ACT-COMPARE-HOOK-002: another tour's comparison is not this one's, and is left alone when this detail closes", () => {
useTourActivityCompareStore.getState().show({ tourPlaceId: 9, trackPlaceId: 20 });
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
expect(result.current.comparedTrack).toBeNull();
unmount();
expect(useTourActivityCompareStore.getState().comparison).toEqual({ tourPlaceId: 9, trackPlaceId: 20 });
});
it('FE-ACT-COMPARE-HOOK-003: closing the detail ends its comparison', () => {
const { result, unmount } = renderHook(() => useActivityComparison(7, activities));
act(() => result.current.toggle(20));
unmount();
expect(useTourActivityCompareStore.getState().comparison).toBeNull();
});
it('FE-ACT-COMPARE-HOOK-004: a track no longer linked stops being compared; one still loading is kept', () => {
const { result, rerender } = renderHook(({ list }) => useActivityComparison(7, list), {
initialProps: { list: activities as TourActivity[] | null },
});
act(() => result.current.toggle(20));
rerender({ list: null });
expect(result.current.comparedTrack).toBe(20);
rerender({ list: [linked(21)] });
expect(result.current.comparedTrack).toBeNull();
});
});
@@ -0,0 +1,43 @@
import type { TourActivity } from '@trek/shared';
import { useCallback, useEffect } from 'react';
import { useTourActivityCompareStore } from '../../../store/tourActivityCompareStore';
/**
* ACT-02 from the tour's detail: which of its actual tracks the plan map
* compares with the plan, and the switch for it. One at a time: comparing
* another ends the first. The comparison ends with the detail, and when the
* track it shows is no longer linked to this tour, so the map never compares
* a plan with a track that is not its own.
*/
export function useActivityComparison(tourPlaceId: number, activities: readonly TourActivity[] | null) {
const comparedTrack = useTourActivityCompareStore((state) =>
state.comparison?.tourPlaceId === tourPlaceId ? state.comparison.trackPlaceId : null
);
useEffect(() => {
if (comparedTrack === null || activities === null) return;
if (!activities.some((activity) => activity.track.place_id === comparedTrack)) {
useTourActivityCompareStore.getState().clear();
}
}, [activities, comparedTrack]);
useEffect(
() => () => {
const { comparison, clear } = useTourActivityCompareStore.getState();
if (comparison?.tourPlaceId === tourPlaceId) clear();
},
[tourPlaceId]
);
const toggle = useCallback(
(trackPlaceId: number) => {
const { show } = useTourActivityCompareStore.getState();
show(comparedTrack === trackPlaceId ? null : { tourPlaceId, trackPlaceId });
},
[comparedTrack, tourPlaceId]
);
return { comparedTrack, toggle };
}
export type ActivityComparisonState = ReturnType<typeof useActivityComparison>;
+8 -1
View File
@@ -434,19 +434,26 @@ export function CardAction({
icon,
onClick,
disabled = false,
pressed,
children,
}: {
icon: ReactNode
onClick: () => void
disabled?: boolean
/** Makes it a switch: on, it takes the accent and says so to a screen reader. */
pressed?: boolean
children: ReactNode
}) {
const look = pressed
? 'bg-accent text-accent-text ring-transparent'
: 'bg-surface-card text-content-secondary ring-edge-faint enabled:hover:text-content'
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-full bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content disabled:cursor-default disabled:opacity-40"
aria-pressed={pressed}
className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded-full px-2.5 font-semibold shadow-sm ring-1 ring-inset transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40 ${look}`}
style={fs(11.5, 'body')}
>
<span className="flex-none">{icon}</span>