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 && (