+ );
+}
+
+/** A column head: its name with the line it is drawn as on the map. */
+function ColumnHead({ label, swatch }: { label: string; swatch?: ReactNode }) {
+ return (
+
+
+ {swatch}
+ {label}
+
+
+ );
+}
+
+/** 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 (
+
+
+
+
+
+ } />
+ } />
+
+
+
+
+ {rows.map((row) => {
+ const actual = actualCell(row, unit, t);
+ return (
+
+
+ {t(ROW_LABELS[row.key])}
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+/** 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 (
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/Tours/activity/TourActivitySection.test.tsx b/client/src/components/Tours/activity/TourActivitySection.test.tsx
index 5682946bc..763c6b983 100644
--- a/client/src/components/Tours/activity/TourActivitySection.test.tsx
+++ b/client/src/components/Tours/activity/TourActivitySection.test.tsx
@@ -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 {
} as TourActivities;
}
-const show = (canEdit = true, item: TourListItem = tour) =>
- render();
+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(
+
+ );
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();
+ });
});
diff --git a/client/src/components/Tours/activity/TourActivitySection.tsx b/client/src/components/Tours/activity/TourActivitySection.tsx
index 4b0e259b4..a30830864 100644
--- a/client/src/components/Tours/activity/TourActivitySection.tsx
+++ b/client/src/components/Tours/activity/TourActivitySection.tsx
@@ -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;
-
-/** 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 (
@@ -108,11 +112,21 @@ function ActivityCard({
-
+ comparison.toggle(trackPlaceId) }
+ : undefined
+ }
+ />
{analysis && analysis.distanceIndexedProfileSamples.length >= 2 && (
)}
@@ -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}
/>
))}
@@ -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;
}
@@ -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({
)
}
>
-
+
{state.assigning && (
+
+
+ );
+}
diff --git a/client/src/components/Tours/activity/tourActivityCompare.test.ts b/client/src/components/Tours/activity/tourActivityCompare.test.ts
new file mode 100644
index 000000000..7add581f5
--- /dev/null
+++ b/client/src/components/Tours/activity/tourActivityCompare.test.ts
@@ -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);
+ });
+});
diff --git a/client/src/components/Tours/activity/tourActivityCompare.ts b/client/src/components/Tours/activity/tourActivityCompare.ts
new file mode 100644
index 000000000..a13eec580
--- /dev/null
+++ b/client/src/components/Tours/activity/tourActivityCompare.ts
@@ -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 };
+}
diff --git a/client/src/components/Tours/activity/useActivityCompareMap.test.ts b/client/src/components/Tours/activity/useActivityCompareMap.test.ts
new file mode 100644
index 000000000..307b7dbd2
--- /dev/null
+++ b/client/src/components/Tours/activity/useActivityCompareMap.test.ts
@@ -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');
+ }
+ });
+});
diff --git a/client/src/components/Tours/activity/useActivityCompareMap.ts b/client/src/components/Tours/activity/useActivityCompareMap.ts
new file mode 100644
index 000000000..5cccce89c
--- /dev/null
+++ b/client/src/components/Tours/activity/useActivityCompareMap.ts
@@ -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
{
+ /** 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
{
+ 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