diff --git a/client/src/components/Tours/collection/CollectionToursSection.tsx b/client/src/components/Tours/collection/CollectionToursSection.tsx index 15901035b..a2843f564 100644 --- a/client/src/components/Tours/collection/CollectionToursSection.tsx +++ b/client/src/components/Tours/collection/CollectionToursSection.tsx @@ -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 + {pace && ( + + + }>{pace} + + + )} + {stages && ( + + }>{stages} + + )} + + ); +} + /** * 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({
+ {tour.waypoint_count > 0 && ( diff --git a/client/src/components/Tours/collection/collectionTours.test.tsx b/client/src/components/Tours/collection/collectionTours.test.tsx index 3f6b8230d..2cb9e2159 100644 --- a/client/src/components/Tours/collection/collectionTours.test.tsx +++ b/client/src/components/Tours/collection/collectionTours.test.tsx @@ -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> }) => ( @@ -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(); + 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()] }); diff --git a/server/src/nest/tours/collection-tours.helpers.ts b/server/src/nest/tours/collection-tours.helpers.ts index f2e057af0..cead1e41b 100644 --- a/server/src/nest/tours/collection-tours.helpers.ts +++ b/server/src/nest/tours/collection-tours.helpers.ts @@ -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): 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, diff --git a/server/tests/unit/nest/collection-tours.helpers.test.ts b/server/tests/unit/nest/collection-tours.helpers.test.ts index 6154caa40..4ca1b939c 100644 --- a/server/tests/unit/nest/collection-tours.helpers.test.ts +++ b/server/tests/unit/nest/collection-tours.helpers.test.ts @@ -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 }, diff --git a/shared/src/tours/collection-tour.schema.ts b/shared/src/tours/collection-tour.schema.ts index a35945dfd..c58c70e50 100644 --- a/shared/src/tours/collection-tour.schema.ts +++ b/shared/src/tours/collection-tour.schema.ts @@ -41,7 +41,12 @@ export const collectionTourTakeOverRequestSchema = z .strict(); export type CollectionTourTakeOverRequest = z.infer; -/** 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,