From 2ee4ea1e6568f7e4d21edccc326c60dff93b5f57 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 16:44:55 +0200 Subject: [PATCH] fix(tours): keep a multi-day tour's days in sight in the list row The stage count sat in the row's fixed-height line of badges, so a tour with stages and a recorded track wrapped and lost its days badge. The count now sits on the fold button, a moon with the number at its corner, or beside the row where it does not unfold, and the days come before the measures so the ascent is what gets cut. --- client/src/components/Tours/TourListRow.tsx | 34 ++++++++++---- .../components/Tours/ToursSidebar.test.tsx | 18 +++++++ .../Tours/planner/TourPlannerPanels.test.tsx | 6 +++ .../components/Tours/stages/StageParts.tsx | 47 +++++++++++++++---- .../Tours/stages/stageViews.test.tsx | 3 +- client/src/components/Tours/tourParts.tsx | 22 +++++++-- 6 files changed, 108 insertions(+), 22 deletions(-) diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index 89f0de651..a8c60af48 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -7,9 +7,10 @@ import { FoldButton, TourActivityBadge, TourCautionBadge, TourDaysBadge, TourGra import type { Day } from '../../types' import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag' import { TourDayRowPills } from './TourDayRowFacts' -import { TourStagesBadge } from './stages/StageParts' +import { StageCountCorner, TourStagesBadge } from './stages/StageParts' import { useTourDays } from './tourDays' import { Tooltip } from '../shared/Tooltip' +import { useTranslation } from '../../i18n' const NO_DAYS: Day[] = [] @@ -73,16 +74,16 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: { )} {/* One line of whole badges, most important first: one that does not fit wraps onto a - second line the fixed height hides, so a narrow sidebar shows two and never half of one. */} + second line the fixed height hides, so a narrow sidebar shows two and never half of one. + The days come before the measures, so the ascent is what goes, never the days. */}
- - +
@@ -111,6 +112,24 @@ function TourRowActions({ action, mapToggle, selected }: Pick) { + const { t } = useTranslation() + if (!fold) return + const count = tour.stage_count ?? 0 + return ( + // Always in sight, unlike the hover actions: the stages are part of what the row is. + event.stopPropagation()}> + 0 ? : undefined} + hint={count > 0 ? t('tours.stages.count', { count }) : undefined} /> + + ) +} + /** * A tour in a list, laid out like a stop in the day plan: its tile with the * grade, the name, the description below it, and one line of the plan's @@ -154,12 +173,7 @@ export default function TourListRow({ tour, )} - {fold && ( - // Always in sight, unlike the hover actions: the stages are part of what the row is. - event.stopPropagation()}> - - - )} + {children} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index e99fed879..e0da3372b 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -538,6 +538,24 @@ describe('ToursSidebar', () => { expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit') }) + it('MDT-02: the days come before the measures in the badge line, and a multi-day tour counts its stages beside the row', () => { + const days = [{ id: 7, trip_id: 1, day_number: 1, title: null, date: null }] as never[] + seedStore(useTripStore, { assignments: { '7': [{ id: 1, place_id: 42 }] } as never }) + render() + + const row = screen.getByRole('option', { name: /Selected ridge walk/ }) + const metrics = within(row).getByTestId('tour-metrics') + const daysBadge = within(metrics).getByTestId('tour-days-badge') + const measures = [...metrics.querySelectorAll('[aria-label]')].filter(element => !daysBadge.contains(element)) + expect(measures.length).toBeGreaterThan(0) + for (const measure of measures) { + expect(daysBadge.compareDocumentPosition(measure) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + } + expect(metrics).not.toHaveTextContent(/stages/) + expect(within(row).getByRole('img', { name: '3 stages' })).toBeInTheDocument() + expect(metrics).not.toContainElement(within(row).getByTestId('tour-stages-badge')) + }) + it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => { render( { const hutToHut = { ...tour, place_id: 43, name: 'Hut to hut', stage_count: 2 } render() expect(screen.getAllByRole('button', { name: 'Show stages' })).toHaveLength(1) + // The count sits on the fold button, always in sight, never in the row's line of badges. + const unfold = screen.getByRole('button', { name: 'Show stages' }) + expect(unfold).toHaveAccessibleDescription('2 stages') + expect(within(unfold).getByTestId('tour-stages-count')).toHaveTextContent('2') + const row = screen.getByRole('option', { name: /Hut to hut/ }) + expect(within(within(row).getByTestId('tour-metrics')).queryByText(/stages/)).toBeNull() expect(screen.queryByTestId('tour-stage-overview')).toBeNull() await userEvent.click(screen.getByRole('button', { name: 'Show stages' })) expect(screen.getByTestId('tour-stage-overview')).toHaveAttribute('id', 'tour-stages-43') diff --git a/client/src/components/Tours/stages/StageParts.tsx b/client/src/components/Tours/stages/StageParts.tsx index 2d96242f9..74397bce4 100644 --- a/client/src/components/Tours/stages/StageParts.tsx +++ b/client/src/components/Tours/stages/StageParts.tsx @@ -199,15 +199,46 @@ export function stageMenuItems(stages: TourStagesController, point: { id: string return items; } -/** MDT-01: a multi-day tour in a list says how many stages it has, with the moon of its nights. */ -export function TourStagesBadge({ tour }: { tour: Pick }) { - const { t } = useTranslation(); - if (!tour.stage_count) return null; +/** + * MDT-02: how many stages a multi-day tour has, as a small moon with the + * number beside it, in the corner of what it sits on. Its words are said by + * whatever carries it. + */ +export function StageCountCorner({ count }: { count: number }) { return ( - - }> - {t('tours.stages.count', { count: tour.stage_count })} - + ); } + +/** + * MDT-01: a multi-day tour in a list says how many stages it has: a moon with + * the count at its corner, beside the row rather than in its line of badges, + * so it is always in sight and never pushes the days it is on out of it. A row + * that unfolds its stages carries the count on its fold button instead. + */ +export function TourStagesBadge({ tour }: { tour: Pick }) { + const { t } = useTranslation(); + if (!tour.stage_count) return null; + const label = t('tours.stages.count', { count: tour.stage_count }); + return ( + + + + + ); +} diff --git a/client/src/components/Tours/stages/stageViews.test.tsx b/client/src/components/Tours/stages/stageViews.test.tsx index 7516bebae..f36873fe9 100644 --- a/client/src/components/Tours/stages/stageViews.test.tsx +++ b/client/src/components/Tours/stages/stageViews.test.tsx @@ -93,7 +93,8 @@ describe('stage views', () => { it('FE-TOUR-STAGE-VIEW-003: a multi-day tour in a list says how many stages it has; a day tour says nothing', () => { const { rerender } = render(); - expect(screen.getByTestId('tour-stages-badge')).toHaveTextContent('3 stages'); + expect(screen.getByRole('img', { name: '3 stages' })).toBe(screen.getByTestId('tour-stages-badge')); + expect(screen.getByTestId('tour-stages-count')).toHaveTextContent('3'); rerender(); expect(screen.queryByTestId('tour-stages-badge')).toBeNull(); }); diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index 4d412d0e7..9d1006883 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -274,12 +274,28 @@ export function TourNotice({ tone = 'neutral', icon, children, role }: { tone?: } /** The chevron that folds a section away, quiet like the rest of a panel's controls. */ -export function FoldButton({ expanded, onToggle, controls, label }: { expanded: boolean; onToggle: () => void; controls: string; label: string }) { +/** + * A chevron that folds what `controls` names. `corner` sits at its top corner + * (a count of what unfolds), and `hint` says the same in words: in the tooltip + * after the label, and as the button's description for assistive tech. + */ +export function FoldButton({ expanded, onToggle, controls, label, corner, hint }: { + expanded: boolean + onToggle: () => void + controls: string + label: string + corner?: ReactNode + hint?: string +}) { + const hintId = useId() return ( - + )