fix(tours): list a kept tour with its type's marks, pace and stages

The collection copy keeps the winter, run, ski, bike and stage columns,
but its list item only carried the older fields, so a kept ski track lost
its discipline mark, a snowshoe hike its winter mark, a run its pace and
a multi-day tour its stage count. The item now carries them, and a kept
tour's card shows the pace and the stages beside its measures.
This commit is contained in:
Maurice
2026-10-20 16:48:00 +02:00
parent 6b1488994f
commit f5be556c27
5 changed files with 130 additions and 4 deletions
@@ -1,11 +1,12 @@
import type { CollectionTourItem } from '@trek/shared';
import { AlertTriangle, CopyPlus, Route, Trash2, Waypoints, WifiOff } from 'lucide-react';
import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { WhiteBadge } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourMeasurePills } from '../library/tourPreviewParts';
import { runPaceText } from '../run/runPaceText';
import { CardAction, TourGradeTile, TourNotice } from '../tourParts';
import { CollectionTourTakeOverDialog } from './CollectionTourTakeOverDialog';
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
@@ -17,6 +18,33 @@ function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record<s
return tour.saved_by ? `${source}. ${t('tours.collection.keptBy', { name: tour.saved_by })}` : source;
}
/**
* What the trip's list shows of a kept tour beyond its measures: a run's pace
* and a multi-day tour's stages, each a badge whose tooltip names it.
*/
function KeptTourTypeBadges({ tour }: { tour: CollectionTourItem }) {
const { t } = useTranslation();
const pace = runPaceText(tour, t);
const stages = tour.stage_count ? t('tours.stages.count', { count: tour.stage_count }) : null;
const icon = 'flex-none text-content-faint';
return (
<>
{pace && (
<Tooltip label={t('tours.run.pace')}>
<span className="inline-flex" data-testid="kept-tour-pace" aria-label={`${t('tours.run.pace')}: ${pace}`}>
<WhiteBadge icon={<Gauge size={10} strokeWidth={2.2} className={icon} />}>{pace}</WhiteBadge>
</span>
</Tooltip>
)}
{stages && (
<span className="inline-flex" data-testid="kept-tour-stages">
<WhiteBadge icon={<Moon size={10} strokeWidth={2.2} className={icon} />}>{stages}</WhiteBadge>
</span>
)}
</>
);
}
/**
* One kept tour as a card: its grade tile with where it came from, its name
* and type, its measures and waypoints as badges, the trip it was saved from,
@@ -66,6 +94,7 @@ function KeptTourCard({
</div>
<div className="flex flex-wrap gap-1.5">
<TourMeasurePills tour={tour} testPrefix="kept-tour" />
<KeptTourTypeBadges tour={tour} />
{tour.waypoint_count > 0 && (
<Tooltip label={t('tours.collection.points', { count: tour.waypoint_count })}>
<span className="inline-flex" aria-label={t('tours.collection.points', { count: tour.waypoint_count })}>
@@ -17,7 +17,7 @@ import { TourSaveToCollectionBarButton, TourSaveToCollectionButton } from './Tou
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
import { useSaveTourToCollection } from './useSaveTourToCollection';
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-014
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-015
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
@@ -366,6 +366,29 @@ describe('CollectionToursSection', () => {
await waitFor(() => expect(screen.queryByTestId('collection-tours')).toBeNull());
});
it('FE-COLLECTION-TOURS-015: a kept tour looks as it did in its trip list: winter mark, run pace and stages', async () => {
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({
tours: [
kept({ id: 5, winter_activity: 'snowshoe', stage_count: 3 }),
kept({
id: 6,
tour_type: 'run',
max_hiking_difficulty: null,
run_profile: 'trail',
run_pace_seconds_per_km: 360,
}),
],
});
render(<CollectionToursSection collectionId={3} canRemove={false} />);
const hike = await screen.findByTestId('kept-tour-5');
expect(within(hike).getByTestId('tour-winter-mark')).toBeInTheDocument();
expect(within(hike).getByTestId('kept-tour-stages')).toHaveTextContent('3 stages');
expect(within(hike).queryByTestId('kept-tour-pace')).toBeNull();
const run = screen.getByTestId('kept-tour-6');
expect(within(run).getByTestId('kept-tour-pace')).toHaveTextContent('6:00');
expect(within(run).queryByTestId('kept-tour-stages')).toBeNull();
});
it('FE-COLLECTION-TOURS-013: a viewer sees no remove, a failed read says so, and the Tours addon off shows nothing', async () => {
const list = vi.spyOn(collectionToursRepo, 'list').mockRejectedValueOnce(new Error('500'));
list.mockResolvedValue({ tours: [kept()] });
@@ -6,13 +6,29 @@ import { parseRouteGeometry, thinnedLine } from './tours.helpers';
import {
COLLECTION_TOUR_PREVIEW_POINTS,
walkStationFacts,
type BikeProfile,
type CollectionTourItem,
type CollectionTourPoint,
type RunProfile,
type SkiDiscipline,
type SkiTechnique,
type TourOrigin,
type TourTypeKey,
type WinterActivity,
} from '@trek/shared';
/** LIB-03: a kept tour as the collection lists it, its measures as they were saved, null staying null. */
/** MDT-01: a kept multi-day tour's stages, one more than the stops that end one, as the trip's list counts them. */
function keptStageCount(row: Pick<CollectionTourRow, 'waypoints'>): number | null {
const ends = row.waypoints.filter((point) => point.stage_end === true).length;
return ends > 0 ? ends + 1 : null;
}
/**
* LIB-03: a kept tour as the collection lists it, its measures as they were
* saved, null staying null, with what the trip's list shows of its type (the
* winter label, the ski discipline, the run's pace, the bike profile, the
* stages), so a kept tour looks as it did in the trip it came from.
*/
export function keptTourItem(row: CollectionTourRow): CollectionTourItem {
return {
id: row.id,
@@ -28,6 +44,13 @@ export function keptTourItem(row: CollectionTourRow): CollectionTourItem {
break_additional_minutes: row.break_additional_minutes,
max_hiking_difficulty: row.max_hiking_difficulty,
wanderer_ref: row.wanderer_ref,
bike_profile: row.bike_profile as BikeProfile | null,
winter_activity: row.winter_activity as WinterActivity | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline as SkiDiscipline | null,
ski_technique: row.ski_technique as SkiTechnique | null,
stage_count: keptStageCount(row),
origin: row.origin as TourOrigin,
has_line: row.route_geometry !== null,
waypoint_count: row.waypoints.length,
@@ -41,6 +41,11 @@ const row: CollectionTourRow = {
match_confidence: 1,
max_hiking_difficulty: null,
bike_profile: 'gravel',
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
ski_technique: null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
@@ -50,6 +55,8 @@ const row: CollectionTourRow = {
hiking_preferences: null,
origin: 'gpx',
origin_file_name: 'ridge.gpx',
track_started_at: null,
track_ended_at: null,
source_trip_title: 'Alps',
source_tour_name: 'Ridge',
created_at: '2026-10-20 10:00:00',
@@ -82,6 +89,38 @@ describe('collection tour helpers (LIB-03)', () => {
expect(keptTourItem({ ...row, route_geometry: null }).has_line).toBe(false);
});
it('LIB3-HLP-007: a kept tour carries what the trip list shows of its type, and its stages', () => {
expect(keptTourItem(row)).toMatchObject({
bike_profile: 'gravel',
winter_activity: null,
ski_discipline: null,
run_pace_seconds_per_km: null,
stage_count: null,
});
const ski = {
...row,
tour_type: 'ski',
bike_profile: null,
ski_discipline: 'cross_country',
ski_technique: 'skating',
};
expect(keptTourItem(ski)).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' });
const run = { ...row, tour_type: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 360 };
expect(keptTourItem(run)).toMatchObject({ run_profile: 'trail', run_pace_seconds_per_km: 360 });
const snowshoe = { ...row, tour_type: 'hike', bike_profile: null, winter_activity: 'snowshoe' };
expect(keptTourItem(snowshoe).winter_activity).toBe('snowshoe');
const hutToHut = {
...row,
waypoints: [
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via' as const, sequence: 1, stage_end: true },
{ lat: 47.015, lng: 11.015, role: 'via' as const, sequence: 2, stage_end: true },
{ lat: 47.02, lng: 11.02, role: 'end' as const, sequence: 3 },
],
};
expect(keptTourItem(hutToHut).stage_count).toBe(3);
});
it('LIB3-HLP-003: shows the points in route order and the line thinned for drawing', () => {
expect(keptTourPoints(row)).toEqual([
{ role: 'start', name: null, shaping: false },
+13 -1
View File
@@ -41,7 +41,12 @@ export const collectionTourTakeOverRequestSchema = z
.strict();
export type CollectionTourTakeOverRequest = z.infer<typeof collectionTourTakeOverRequestSchema>;
/** A tour kept in a collection as the collection lists it: its measures as they were saved, null staying null. */
/**
* A tour kept in a collection as the collection lists it: its measures as
* they were saved, null staying null, and what the trip's list shows of its
* type (winter label, ski discipline and technique, run profile and pace,
* bike profile, stage count).
*/
export const collectionTourItemSchema = tourListItemSchema
.pick({
name: true,
@@ -58,6 +63,13 @@ export const collectionTourItemSchema = tourListItemSchema
station_count: true,
station_stay_minutes: true,
station_stays_unknown: true,
bike_profile: true,
winter_activity: true,
run_profile: true,
run_pace_seconds_per_km: true,
ski_discipline: true,
ski_technique: true,
stage_count: true,
})
.extend({
id: idSchema,