From 6a0c54cb2fdcefaf082c9c82987dd19d1bd75d54 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 13:06:17 +0200 Subject: [PATCH] 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. --- .../Tours/TourDetailDialog.test.tsx | 36 +++ .../src/components/Tours/TourDetailDialog.tsx | 25 +- .../activity/ActivityCompareLegend.test.tsx | 27 ++ .../Tours/activity/ActivityCompareLegend.tsx | 74 ++++++ .../activity/TourActivityCompare.test.tsx | 142 +++++++++++ .../Tours/activity/TourActivityCompare.tsx | 231 ++++++++++++++++++ .../activity/TourActivitySection.test.tsx | 56 ++++- .../Tours/activity/TourActivitySection.tsx | 78 ++++-- .../Tours/activity/activityCompareParts.tsx | 28 +++ .../activity/tourActivityCompare.test.ts | 108 ++++++++ .../Tours/activity/tourActivityCompare.ts | 104 ++++++++ .../activity/useActivityCompareMap.test.ts | 117 +++++++++ .../Tours/activity/useActivityCompareMap.ts | 92 +++++++ .../activity/useActivityComparison.test.ts | 61 +++++ .../Tours/activity/useActivityComparison.ts | 43 ++++ client/src/components/Tours/tourParts.tsx | 9 +- client/src/pages/TripPlannerPage.tsx | 2 +- .../pages/tripPlanner/PlanMapStage.test.tsx | 20 +- client/src/pages/tripPlanner/PlanMapStage.tsx | 20 +- client/src/store/tourActivityCompareStore.ts | 26 ++ client/tests/helpers/storeRegistry.ts | 2 + shared/src/i18n/ar/tours.ts | 14 ++ shared/src/i18n/az/tours.ts | 14 ++ shared/src/i18n/br/tours.ts | 14 ++ shared/src/i18n/ca/tours.ts | 14 ++ shared/src/i18n/cs/tours.ts | 14 ++ shared/src/i18n/de/tours.ts | 15 ++ shared/src/i18n/en/tours.ts | 14 ++ shared/src/i18n/es/tours.ts | 14 ++ shared/src/i18n/et/tours.ts | 14 ++ shared/src/i18n/fr/tours.ts | 14 ++ shared/src/i18n/gr/tours.ts | 16 ++ shared/src/i18n/hu/tours.ts | 15 ++ shared/src/i18n/id/tours.ts | 14 ++ shared/src/i18n/it/tours.ts | 14 ++ shared/src/i18n/ja/tours.ts | 14 ++ shared/src/i18n/ko/tours.ts | 14 ++ shared/src/i18n/nl/tours.ts | 14 ++ shared/src/i18n/pl/tours.ts | 14 ++ shared/src/i18n/ru/tours.ts | 14 ++ shared/src/i18n/sk/tours.ts | 14 ++ shared/src/i18n/sv/tours.ts | 14 ++ shared/src/i18n/th/tours.ts | 14 ++ shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 14 ++ shared/src/i18n/uk/tours.ts | 14 ++ shared/src/i18n/vi/tours.ts | 14 ++ shared/src/i18n/zh-TW/tours.ts | 14 ++ shared/src/i18n/zh/tours.ts | 14 ++ shared/src/tours/route-corridor.spec.ts | 32 ++- shared/src/tours/route-corridor.ts | 38 ++- 51 files changed, 1699 insertions(+), 56 deletions(-) create mode 100644 client/src/components/Tours/activity/ActivityCompareLegend.test.tsx create mode 100644 client/src/components/Tours/activity/ActivityCompareLegend.tsx create mode 100644 client/src/components/Tours/activity/TourActivityCompare.test.tsx create mode 100644 client/src/components/Tours/activity/TourActivityCompare.tsx create mode 100644 client/src/components/Tours/activity/activityCompareParts.tsx create mode 100644 client/src/components/Tours/activity/tourActivityCompare.test.ts create mode 100644 client/src/components/Tours/activity/tourActivityCompare.ts create mode 100644 client/src/components/Tours/activity/useActivityCompareMap.test.ts create mode 100644 client/src/components/Tours/activity/useActivityCompareMap.ts create mode 100644 client/src/components/Tours/activity/useActivityComparison.test.ts create mode 100644 client/src/components/Tours/activity/useActivityComparison.ts create mode 100644 client/src/store/tourActivityCompareStore.ts diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index abddc35dd..d7e417d2d 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -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() + + 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() + 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() diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 3071dd169..23bf2b0f9 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -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( + () => ({ + 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 }} /> @@ -734,6 +746,9 @@ export default function TourDetailDialog({ days={days} assignments={assignments} canEdit={canEdit} + plan={plan} + planColor={trackColor} + mapCompare={desktopNonModal} onToursChanged={onToursChanged} /> )} diff --git a/client/src/components/Tours/activity/ActivityCompareLegend.test.tsx b/client/src/components/Tours/activity/ActivityCompareLegend.test.tsx new file mode 100644 index 000000000..fbb4017f6 --- /dev/null +++ b/client/src/components/Tours/activity/ActivityCompareLegend.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/client/src/components/Tours/activity/ActivityCompareLegend.tsx b/client/src/components/Tours/activity/ActivityCompareLegend.tsx new file mode 100644 index 000000000..b44ca8588 --- /dev/null +++ b/client/src/components/Tours/activity/ActivityCompareLegend.tsx @@ -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 ( +
  • + + {swatch} + + + {label} + + {name} + + +
  • + ); +} + +/** + * 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 ( + + ); +} diff --git a/client/src/components/Tours/activity/TourActivityCompare.test.tsx b/client/src/components/Tours/activity/TourActivityCompare.test.tsx new file mode 100644 index 000000000..256746699 --- /dev/null +++ b/client/src/components/Tours/activity/TourActivityCompare.test.tsx @@ -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 { + 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[0]> = {}) { + return render( + + ); +} + +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( + + ); + 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'); + }); +}); diff --git a/client/src/components/Tours/activity/TourActivityCompare.tsx b/client/src/components/Tours/activity/TourActivityCompare.tsx new file mode 100644 index 000000000..f21626223 --- /dev/null +++ b/client/src/components/Tours/activity/TourActivityCompare.tsx @@ -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; + +const ROW_LABELS: Record = { + 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 ( + + + {text} + + + ); +} + +/** 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 ; + const text = formatDifference(row.key, row.difference, unit); + const Icon = text.startsWith('±') ? Equal : row.difference > 0 ? TrendingUp : TrendingDown; + return ( + + } + > + {text} + + + ); +} + +/** 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 ( +
    +
    + {t('tours.activity.compare.title')} + {map && activity.geometry.length >= 2 && ( + + + + } onClick={map.onToggle} pressed={map.shown}> + {mapLabel} + + + + + )} +
    + + +
    + ); +} 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 && (