feat(tours): kept tours are rows of a collection's list, on the phone too

A tour kept in a collection was a card in a box of its own above the list,
and its button lost its look to the collections page's button reset. It is
now a row of the list like a saved place: its grade or type tile, its name,
type and the trip it came from, its measures, and Add to a trip, with
removing dimmed beside it. The list's search narrows the tours too. The
phone shows the same rows and takes a tour into a trip from a sheet, on the
same hooks as the desktop.
This commit is contained in:
Maurice
2026-10-20 20:48:33 +02:00
parent 810feba413
commit 76ec302526
13 changed files with 670 additions and 138 deletions
+9
View File
@@ -223,6 +223,15 @@
"desktop": ["src/pages/CollectionsPage.tsx"],
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
},
{
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
"hooks": [
"src/components/Tours/collection/useCollectionTours.ts",
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
],
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
},
{
"feature": "Copy collection places to a trip",
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
@@ -1,26 +1,24 @@
import type { CollectionTourItem } from '@trek/shared';
import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react';
import type { MouseEvent } from 'react';
import { useTranslation } from '../../../i18n';
import { useAuthStore } from '../../../store/authStore';
import { useCollectionStore } from '../../../store/collectionStore';
import { useSettingsStore } from '../../../store/settingsStore';
import { canRemoveFromCollection, collectionRoleOf } from '../../../utils/collectionAccess';
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 { tourDayRowIcon } from '../tourDayRowIcon';
import { TourGradeTile, TourNotice } from '../tourParts';
import { CollectionTourTakeOverDialog } from './CollectionTourTakeOverDialog';
import { sourceText } from './keptTourText';
import {
toursMatching,
useActiveCollectionTourList,
useCollectionTours,
type CollectionTours,
} from './useCollectionTours';
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
import { useCollectionTours, type CollectionTours } from './useCollectionTours';
/** Where a kept tour came from and who kept it, as one tooltip. */
function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record<string, unknown>) => string) {
const source = t('tours.collection.source', { tour: tour.source.tourName, trip: tour.source.tripTitle });
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
@@ -51,12 +49,13 @@ function KeptTourTypeBadges({ tour }: { tour: CollectionTourItem }) {
}
/**
* 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,
* and the way into a trip. Removing it sits quietly in its corner, dimmed
* but always there (a phone has no hover), raised on hover or focus.
* One kept tour as a row of the collection's list, the way a saved place is:
* its grade tile where a place has its photo, its name with its type and the
* trip it was kept from, then its measures, the way into a trip and, for its
* owner or an admin, removing it. The whole row takes it into a trip, as the
* whole row of a place opens the place.
*/
function KeptTourCard({
function KeptTourRow({
tour,
canRemove,
onTake,
@@ -69,68 +68,82 @@ function KeptTourCard({
}) {
const { t } = useTranslation();
const tile = { ...tour, has_waypoints: tour.waypoint_count > 0 };
const TypeIcon = tourDayRowIcon(tour.tour_type);
const points = t('tours.collection.points', { count: tour.waypoint_count });
const stop = (act: () => void) => (event: MouseEvent) => {
event.stopPropagation();
act();
};
return (
<article
<div
role="button"
tabIndex={0}
data-no-press
data-testid={`kept-tour-${tour.id}`}
className="group flex min-w-0 flex-col gap-2.5 rounded-2xl border border-edge-faint bg-surface-card p-3 shadow-sm transition-shadow hover:shadow-md"
onClick={onTake}
onKeyDown={(event) => {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
onTake();
}}
className="col-lrow col-lrow-tour group"
>
<div className="flex min-w-0 items-start gap-3">
<span className="col-lrow-tile">
<TourGradeTile tour={tile} />
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
<h3 className="m-0 truncate font-semibold text-content" style={fs(13.5, 'body')}>
{tour.name}
</h3>
<span className="truncate text-content-faint" style={fs(11, 'body')}>
{t(`tourTypes.${tour.tour_type}`)}
</span>
</span>
<div className="li">
<div className="t">{tour.name}</div>
<div className="s">
<TypeIcon size={11} />
<span>{t(`tourTypes.${tour.tour_type}`)}</span>
<Tooltip label={sourceText(tour, t)}>
<span className="col-lrow-from">
<Route size={11} />
<span>{tour.source.tripTitle}</span>
</span>
</Tooltip>
</div>
</div>
<div className="col-lrow-end">
<span className="col-lrow-measures">
<TourMeasurePills tour={tour} testPrefix="kept-tour" />
<KeptTourTypeBadges tour={tour} />
{tour.waypoint_count > 0 && (
<Tooltip label={points}>
<span className="inline-flex" aria-label={points}>
<WhiteBadge icon={<Waypoints size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{tour.waypoint_count}
</WhiteBadge>
</span>
</Tooltip>
)}
</span>
<span className="col-lrow-div" aria-hidden />
<Tooltip label={t('tours.collection.addHint')}>
<button type="button" className="col-lrow-act" onClick={stop(onTake)}>
<CopyPlus size={13} strokeWidth={2} />
{t('tours.collection.add')}
</button>
</Tooltip>
{canRemove && (
<Tooltip label={t('tours.collection.remove')}>
<button
type="button"
onClick={onRemove}
onClick={stop(onRemove)}
aria-label={t('tours.collection.remove')}
data-testid={`kept-tour-remove-${tour.id}`}
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint opacity-50 transition-opacity hover:bg-danger-soft hover:text-danger focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent group-hover:opacity-100"
className="col-lrow-remove opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
>
<Trash2 size={13} strokeWidth={2} />
<Trash2 size={14} strokeWidth={2} />
</button>
</Tooltip>
)}
</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 })}>
<WhiteBadge icon={<Waypoints size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{tour.waypoint_count}
</WhiteBadge>
</span>
</Tooltip>
)}
</div>
<div className="mt-auto flex min-w-0 items-center gap-2 border-t border-edge-faint pt-2.5">
<Tooltip label={sourceText(tour, t)}>
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-content-muted" style={fs(11, 'body')}>
<Route size={12} strokeWidth={2} className="flex-none text-content-faint" />
<span className="truncate">{tour.source.tripTitle}</span>
</span>
</Tooltip>
<Tooltip label={t('tours.collection.addHint')}>
<span className="inline-flex">
<CardAction icon={<CopyPlus size={12} strokeWidth={2} />} onClick={onTake}>
{t('tours.collection.add')}
</CardAction>
</span>
</Tooltip>
</div>
</article>
</div>
);
}
/** What stands above the cards while they cannot be shown: offline, or failed with a retry. */
/** What stands above the rows while they cannot be shown: offline, or failed with a retry. */
function SectionProblem({ kept }: { kept: CollectionTours }) {
const { t } = useTranslation();
if (kept.status === 'offline')
@@ -149,41 +162,38 @@ function SectionProblem({ kept }: { kept: CollectionTours }) {
}
/**
* LIB-03 on a collection's page: the whole tours it keeps, above its places,
* each ready to be taken into a trip. Nothing shows while it keeps none, or
* while the Tours addon is off.
* LIB-03 on a collection's page: the whole tours it keeps, as the first rows
* of its list, each ready to be taken into a trip. The list's search narrows
* them by name as it narrows the places. Nothing shows while it keeps none,
* or while the Tours addon is off.
*/
export function CollectionToursSection({ collectionId, canRemove }: { collectionId: number; canRemove: boolean }) {
export function CollectionToursSection({
collectionId,
canRemove,
search = '',
}: {
collectionId: number;
canRemove: boolean;
search?: string;
}) {
const { t } = useTranslation();
const kept = useCollectionTours(collectionId);
const takeOver = useCollectionTourTakeOver(collectionId);
const problem = kept.status === 'error' || kept.status === 'offline';
if (!kept.enabled || (kept.tours.length === 0 && !problem)) return null;
const shown = toursMatching(kept.tours, search);
if (!kept.enabled || (shown.length === 0 && !problem)) return null;
return (
<section aria-label={t('tours.title')} className="mb-5 flex flex-col gap-2.5" data-testid="collection-tours">
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-accent-subtle text-accent-on">
<Route size={13} strokeWidth={2.2} />
</span>
<h2 className="m-0 font-semibold text-content" style={fs(13, 'body')}>
{t('tours.title')}
</h2>
{kept.tours.length > 0 && <WhiteBadge>{kept.tours.length}</WhiteBadge>}
</div>
<section aria-label={t('tours.title')} className="col-listview col-tourrows" data-testid="collection-tours">
<SectionProblem kept={kept} />
{kept.tours.length > 0 && (
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3">
{kept.tours.map((tour) => (
<KeptTourCard
key={tour.id}
tour={tour}
canRemove={canRemove}
onTake={() => takeOver.show(tour)}
onRemove={() => kept.askRemove(tour)}
/>
))}
</div>
)}
{shown.map((tour) => (
<KeptTourRow
key={tour.id}
tour={tour}
canRemove={canRemove}
onTake={() => takeOver.show(tour)}
onRemove={() => kept.askRemove(tour)}
/>
))}
<CollectionTourTakeOverDialog takeOver={takeOver} />
<ConfirmDialog
isOpen={kept.removing !== null}
@@ -199,17 +209,9 @@ export function CollectionToursSection({ collectionId, canRemove }: { collection
);
}
/**
* The tours of the list open on the collections page, with what the user may
* do there read from the list itself; nothing on "All saved" or before a list opens.
*/
export function ActiveCollectionTours() {
const activeId = useCollectionStore((state) => state.activeId);
const collections = useCollectionStore((state) => state.collections);
const members = useCollectionStore((state) => state.members);
const userId = useAuthStore((state) => state.user?.id);
if (typeof activeId !== 'number') return null;
const isOwner = collections.find((collection) => collection.id === activeId)?.is_owner ?? false;
const canRemove = canRemoveFromCollection(collectionRoleOf(isOwner, members, userId));
return <CollectionToursSection collectionId={activeId} canRemove={canRemove} />;
/** The tours of the list open on the collections page; nothing on "All saved" or before a list opens. */
export function ActiveCollectionTours({ search = '' }: { search?: string }) {
const { collectionId, canRemove } = useActiveCollectionTourList();
if (collectionId === null) return null;
return <CollectionToursSection collectionId={collectionId} canRemove={canRemove} search={search} />;
}
@@ -20,7 +20,9 @@ function KeepButton({ target }: { target: TourSaveTarget }) {
aria-label={t('tours.collection.save')}
data-testid="tour-keep-in-collection"
icon={<BookmarkPlus size={14} strokeWidth={2} />}
/>
>
<span className="max-sm:hidden">{t('tours.collection.list')}</span>
</DialogButton>
</span>
</Tooltip>
<SaveTourToCollectionDialog keep={keep} tourName={target.name} />
@@ -18,7 +18,7 @@ import { TourSaveToCollectionBarButton, TourSaveToCollectionButton } from './Tou
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
import { useSaveTourToCollection } from './useSaveTourToCollection';
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-016
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-017
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
@@ -433,4 +433,16 @@ describe('CollectionToursSection', () => {
useCollectionStore.setState(before, true);
}
});
it('FE-COLLECTION-TOURS-017: the rows follow the list search by name, and a click on the row itself starts the take-over', async () => {
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [kept(), kept({ id: 5, name: 'Lake loop' })] });
const { rerender } = render(<CollectionToursSection collectionId={3} canRemove search="lake" />);
expect(await screen.findByTestId('kept-tour-5')).toBeInTheDocument();
expect(screen.queryByTestId('kept-tour-4')).toBeNull();
rerender(<CollectionToursSection collectionId={3} canRemove search="nothing" />);
expect(screen.queryByTestId('collection-tours')).toBeNull();
rerender(<CollectionToursSection collectionId={3} canRemove search="" />);
fireEvent.click(await screen.findByTestId('kept-tour-4'));
expect(await screen.findByTestId('kept-tour-preview')).toBeInTheDocument();
});
});
@@ -0,0 +1,7 @@
import type { CollectionTourItem } from '@trek/shared';
/** Where a kept tour came from and who kept it, as one tooltip. */
export function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record<string, unknown>) => string) {
const source = t('tours.collection.source', { tour: tour.source.tourName, trip: tour.source.tripTitle });
return tour.saved_by ? `${source}. ${t('tours.collection.keptBy', { name: tour.saved_by })}` : source;
}
@@ -4,7 +4,10 @@ import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useCollectionStore } from '../../../store/collectionStore';
import { useCollectionTourStore, watchCollectionTours } from '../../../store/collectionTourStore';
import { canRemoveFromCollection, collectionRoleOf } from '../../../utils/collectionAccess';
import { useToast } from '../../shared/Toast';
/**
@@ -54,3 +57,24 @@ export function useCollectionTours(collectionId: number) {
}
export type CollectionTours = ReturnType<typeof useCollectionTours>;
/** The kept tours whose name holds the list's search, all of them without one. */
export function toursMatching(tours: readonly CollectionTourItem[], search: string): CollectionTourItem[] {
const query = search.trim().toLocaleLowerCase();
return query ? tours.filter((tour) => tour.name.toLocaleLowerCase().includes(query)) : [...tours];
}
/**
* The list open on the collections page and whether this user may remove its
* tours (its owner or an admin of it), read from the list itself; no list on
* "All saved" or before one opens. Desktop and phone both ask here.
*/
export function useActiveCollectionTourList(): { collectionId: number | null; canRemove: boolean } {
const { activeId, collections, members } = useCollectionStore(
useShallow((state) => ({ activeId: state.activeId, collections: state.collections, members: state.members }))
);
const userId = useAuthStore((state) => state.user?.id);
if (typeof activeId !== 'number') return { collectionId: null, canRemove: false };
const isOwner = collections.find((collection) => collection.id === activeId)?.is_owner ?? false;
return { collectionId: activeId, canRemove: canRemoveFromCollection(collectionRoleOf(isOwner, members, userId)) };
}
@@ -0,0 +1,33 @@
import { ArrowUp, Clock, Ruler } from 'lucide-react';
import { formatDistance, formatElevation } from '../../../utils/units';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
import type { MeasuredTour, Translate, Unit } from './tourPreviewFacts';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
export function measureBadges(tour: MeasuredTour, unit: Unit, t: Translate) {
const times = tourPlannedTimes(tour);
const total = plannedTotalText(times, t);
const badge = plannedTotalText(times, t, true);
return [
total != null &&
badge != null && {
key: 'time',
icon: Clock,
value: badge,
label: `${t('tours.planner.plannedTotalDuration')}: ${total}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
value: formatDistance(tour.distance, unit),
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
},
tour.elevation_gain != null &&
tour.tour_type !== 'kayak' && {
key: 'ascent',
icon: ArrowUp,
value: formatElevation(tour.elevation_gain, unit),
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
},
].filter((badge) => !!badge);
}
@@ -1,44 +1,14 @@
import { ArrowUp, Clock, Loader2, Ruler, Spline } from 'lucide-react';
import { Loader2, Spline } from 'lucide-react';
import { useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
import { useTileUrl } from '../../../hooks/useTileUrl';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { fs } from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
import type { MeasuredTour, Translate, Unit } from './tourPreviewFacts';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: MeasuredTour, unit: Unit, t: Translate) {
const times = tourPlannedTimes(tour);
const total = plannedTotalText(times, t);
const badge = plannedTotalText(times, t, true);
return [
total != null &&
badge != null && {
key: 'time',
icon: Clock,
value: badge,
label: `${t('tours.planner.plannedTotalDuration')}: ${total}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
value: formatDistance(tour.distance, unit),
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
},
tour.elevation_gain != null &&
tour.tour_type !== 'kayak' && {
key: 'ascent',
icon: ArrowUp,
value: formatElevation(tour.elevation_gain, unit),
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
},
].filter((badge) => !!badge);
}
import { measureBadges } from './tourMeasureBadges';
import type { MeasuredTour } from './tourPreviewFacts';
/** The measures of a listed tour as small white badges, each named in full by its tooltip. */
export function TourMeasurePills({ tour, testPrefix }: { tour: MeasuredTour; testPrefix: string }) {
@@ -0,0 +1,132 @@
import type { CollectionTourDetailResponse, CollectionTourItem, TourCreateResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import { collectionToursRepo } from '../../../repo/collectionToursRepo';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { ALL_SAVED, useCollectionStore } from '../../../store/collectionStore';
import { useCollectionTourStore } from '../../../store/collectionTourStore';
import MCollTours from './MCollTours';
// FE-M-COLLECTION-TOURS-001 to FE-M-COLLECTION-TOURS-004
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../components/shared/Toast', () => ({ useToast: () => toast }));
function kept(over: Partial<CollectionTourItem> = {}): CollectionTourItem {
return {
id: 4,
collection_id: 3,
name: 'Ridge',
description: null,
tour_type: 'hike',
distance: 8.2,
elevation_gain: 600,
elevation_loss: 590,
duration: 180,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 2,
wanderer_ref: null,
origin: 'trek',
has_line: true,
waypoint_count: 4,
source: { tripTitle: 'Alps', tourName: 'Ridge' },
saved_by: 'mia',
saved_at: '2026-10-20 10:00:00',
...over,
} as CollectionTourItem;
}
const DETAIL: CollectionTourDetailResponse = {
tour: kept(),
line: [
[47, 11],
[47.1, 11.1],
],
points: [
{ role: 'start', name: null, shaping: false },
{ role: 'end', name: null, shaping: false },
],
trips: [
{ id: 6, title: 'Coast', start_date: '2026-07-01', end_date: '2026-07-08', can_add: true },
{ id: 7, title: 'Away', start_date: null, end_date: null, can_add: false },
],
};
const before = useCollectionStore.getState();
beforeEach(() => {
useAuthStore.setState({ user: { id: 7 } as never });
useAddonStore.setState({
addons: [
{ id: 'tours', enabled: true },
{ id: 'collections', enabled: true },
] as never,
});
useCollectionTourStore.getState().clear();
useCollectionStore.setState({ activeId: 3, collections: [{ id: 3, is_owner: true }] as never, members: [] });
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({
tours: [kept(), kept({ id: 5, name: 'Lake loop', tour_type: 'kayak', max_hiking_difficulty: null })],
});
vi.spyOn(collectionToursRepo, 'detail').mockResolvedValue(DETAIL);
});
afterEach(() => {
useCollectionStore.setState(before, true);
vi.restoreAllMocks();
});
describe('MCollTours', () => {
it('FE-M-COLLECTION-TOURS-001: lists the kept tours as rows with their grade or type and their measures', async () => {
render(<MCollTours search="" />);
const ridge = await screen.findByTestId('m-kept-tour-4');
expect(ridge).toHaveTextContent('Ridge');
expect(ridge).toHaveTextContent('T2');
expect(within(ridge).getByLabelText(/8\.2 km/)).toBeInTheDocument();
expect(screen.getByTestId('m-kept-tour-5')).toHaveTextContent('Lake loop');
});
it('FE-M-COLLECTION-TOURS-002: the list search narrows the tours by name, and "All saved" shows none', async () => {
const { rerender } = render(<MCollTours search="lake" />);
expect(await screen.findByTestId('m-kept-tour-5')).toBeInTheDocument();
expect(screen.queryByTestId('m-kept-tour-4')).toBeNull();
rerender(<MCollTours search="nothing" />);
expect(screen.queryByTestId('m-collection-tours')).toBeNull();
useCollectionStore.setState({ activeId: ALL_SAVED });
rerender(<MCollTours search="" />);
expect(screen.queryByTestId('m-collection-tours')).toBeNull();
});
it('FE-M-COLLECTION-TOURS-003: a tap opens the sheet, a trip the user may not add to is dimmed, and the tour goes in with its points', async () => {
const takeOver = vi
.spyOn(collectionToursRepo, 'takeOver')
.mockResolvedValue({ tour: { name: 'Ridge' } } as TourCreateResponse);
render(<MCollTours search="" />);
fireEvent.click(within(await screen.findByTestId('m-kept-tour-4')).getByText('Ridge'));
const coast = await screen.findByRole('radio', { name: /Coast/ });
expect(screen.getByRole('radio', { name: /Away/ })).toBeDisabled();
fireEvent.click(coast);
fireEvent.click(screen.getByRole('radio', { name: /4 waypoints/ }));
fireEvent.click(screen.getByRole('button', { name: /Add to a trip/ }));
await waitFor(() =>
expect(takeOver).toHaveBeenCalledWith(6, {
collection_id: 3,
collection_tour_id: 4,
include_waypoints: true,
name: 'Ridge',
})
);
expect(await screen.findByRole('button', { name: /Open trip/ })).toBeInTheDocument();
});
it('FE-M-COLLECTION-TOURS-004: its owner removes a tour from the sheet after the question', async () => {
const remove = vi.spyOn(collectionToursRepo, 'remove').mockResolvedValue(undefined);
render(<MCollTours search="" />);
fireEvent.click(within(await screen.findByTestId('m-kept-tour-4')).getByText('Ridge'));
await screen.findByRole('radio', { name: /Coast/ });
fireEvent.click(screen.getByRole('button', { name: 'Remove from the collection' }));
const confirms = await screen.findAllByRole('button', { name: 'Remove from the collection' });
fireEvent.click(confirms[confirms.length - 1] as HTMLElement);
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, 4));
});
});
@@ -0,0 +1,312 @@
import type { CollectionTourItem, CollectionTourTarget } from '@trek/shared';
import { ArrowRight, Check, CopyPlus, Loader2, Route, Trash2 } from 'lucide-react';
import { createElement } from 'react';
import ConfirmDialog from '../../../components/shared/ConfirmDialog';
import { fs } from '../../../components/shared/DialogShell';
import { sourceText } from '../../../components/Tours/collection/keptTourText';
import {
toursMatching,
useActiveCollectionTourList,
useCollectionTours,
} from '../../../components/Tours/collection/useCollectionTours';
import {
useCollectionTourTakeOver,
type CollectionTourTakeOver,
type PointsChoice,
} from '../../../components/Tours/collection/useCollectionTourTakeOver';
import { measureBadges } from '../../../components/Tours/library/tourMeasureBadges';
import { tourDayRowIcon } from '../../../components/Tours/tourDayRowIcon';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import MSheet from '../../components/MSheet';
import { CancelPill, Eyebrow, PrimaryPill, SheetFooter, SheetHeader } from './MCollSheetKit';
/** A kept tour's measures as the small pills of the phone's rows. */
function MeasurePills({ tour }: { tour: CollectionTourItem }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<>
{measureBadges(tour, unit, t)
.slice(0, 2)
.map((badge) => (
<span
key={badge.key}
aria-label={badge.label}
className="box-border inline-flex w-[78px] items-center justify-center gap-1 truncate rounded-full bg-[color:var(--m-ic)] py-[5px] font-geist font-extrabold text-m-ink"
style={fs(9)}
>
{createElement(badge.icon, { size: 9, strokeWidth: 2.6, className: 'flex-none text-m-muted' })}
<span className="truncate">{badge.value}</span>
</span>
))}
</>
);
}
/**
* A kept tour on the phone, shaped like a saved place: the type on a tinted
* tile (a hike's grade where it has one), its name and the trip it was kept
* from, and its time and length where a place has its status and category.
* A tap opens the sheet that takes it into a trip.
*/
function MCollTourRow({ tour, onOpen }: { tour: CollectionTourItem; onOpen: () => void }) {
const { t } = useTranslation();
const grade = tour.max_hiking_difficulty;
return (
<div className="mt-2 flex items-stretch gap-2" data-testid={`m-kept-tour-${tour.id}`}>
<button
type="button"
onClick={onOpen}
className="flex min-w-0 flex-1 items-center gap-[11px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[11px] text-start"
>
<span
className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] font-geist font-extrabold text-m-ink"
style={fs(12)}
>
{grade != null ? `T${grade}` : createElement(tourDayRowIcon(tour.tour_type), { size: 17, strokeWidth: 2.2 })}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-bold text-m-ink" style={fs(12.5)}>
{tour.name}
</span>
<span className="mt-[2px] flex items-center gap-[3px] truncate font-geist text-m-muted" style={fs(10)}>
<Route size={9} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t(`tourTypes.${tour.tour_type}`)}</span>
</span>
</span>
</button>
<div className="flex flex-none flex-col justify-center gap-[5px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[10px] py-2">
<MeasurePills tour={tour} />
</div>
</div>
);
}
/** One trip the tour can go into, chosen like a radio row; one the user may not add to stays, dimmed, with the reason. */
function TripRow({ trip, chosen, onChoose }: { trip: CollectionTourTarget; chosen: boolean; onChoose: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
role="radio"
aria-checked={chosen}
disabled={!trip.can_add}
onClick={onChoose}
className={`mb-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-start disabled:opacity-50 ${chosen ? 'border-[color:var(--m-act)] bg-m-sheetop' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)]'}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-m-ink" style={fs(13)}>
{trip.title}
</span>
{!trip.can_add && (
<span className="block truncate font-geist text-m-muted" style={fs(11)}>
{t('tours.collection.noPermission')}
</span>
)}
</span>
<span
className={`flex h-5 w-5 flex-none items-center justify-center rounded-full ${chosen ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)]'}`}
>
{chosen && <Check size={12} strokeWidth={3} />}
</span>
</button>
);
}
/** With its points or the line alone; a tour without a line can only bring its points. */
function PointsChips({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
const tour = takeOver.tour;
if (!tour) return null;
const chips: Array<{ value: PointsChoice; label: string; disabled: boolean }> = [
{ value: 'with', label: t('tours.collection.withPoints', { count: tour.waypoint_count }), disabled: false },
{ value: 'without', label: t('tours.collection.lineOnly'), disabled: !tour.has_line },
];
return (
<div className="flex flex-wrap gap-2" role="radiogroup">
{chips.map((chip) => (
<button
key={chip.value}
type="button"
role="radio"
aria-checked={takeOver.points === chip.value}
disabled={chip.disabled}
onClick={() => takeOver.choosePoints(chip.value)}
className={`rounded-full px-[13px] py-[7px] font-semibold disabled:opacity-40 ${takeOver.points === chip.value ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'}`}
style={fs(12)}
>
{chip.label}
</button>
))}
</div>
);
}
/** The sheet's body while it asks: the trips, the points and the name in the trip. */
function TakeOverForm({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
if (takeOver.detailStatus === 'loading')
return (
<div className="flex justify-center py-10 text-m-faint">
<Loader2 size={20} className="animate-spin" />
</div>
);
if (takeOver.detailStatus === 'offline' || takeOver.detailStatus === 'error')
return (
<div className="py-6 text-center font-geist text-m-muted" style={fs(12.5)}>
{t(takeOver.detailStatus === 'offline' ? 'tours.collection.offline' : 'tours.library.previewFailed')}
{takeOver.detailStatus === 'error' && (
<button type="button" onClick={takeOver.retry} className="ms-2 font-semibold text-m-ink underline">
{t('tours.planner.retry')}
</button>
)}
</div>
);
return (
<>
<Eyebrow className="mb-2">{t('tours.collection.intoTrip')}</Eyebrow>
{takeOver.trips.length === 0 ? (
<div className="py-4 font-geist text-m-muted" style={fs(12.5)}>
{t('tours.collection.noTrips')}
</div>
) : (
takeOver.trips.map((trip) => (
<TripRow
key={trip.id}
trip={trip}
chosen={takeOver.tripId === trip.id}
onChoose={() => takeOver.chooseTrip(trip.id)}
/>
))
)}
<Eyebrow className="mb-2 mt-4">{t('tours.library.controlPoints')}</Eyebrow>
<PointsChips takeOver={takeOver} />
<Eyebrow className="mb-2 mt-4">{t('tours.collection.nameInTrip')}</Eyebrow>
<input
value={takeOver.name}
onChange={(event) => takeOver.setName(event.target.value)}
className="h-9 w-full rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] font-geist text-m-ink outline-none"
style={fs(12.5)}
/>
</>
);
}
/**
* Taking a kept tour into a trip on the phone: the same choices as the
* desktop dialog, from the same hook, as a sheet. Removing it from the list
* sits at its foot for the list's owner or an admin.
*/
function MCollTourSheet({
takeOver,
canRemove,
onRemove,
}: {
takeOver: CollectionTourTakeOver;
canRemove: boolean;
onRemove: (tour: CollectionTourItem) => void;
}) {
const { t } = useTranslation();
const tour = takeOver.tour;
const title = tour?.name ?? t('tours.collection.add');
return (
<MSheet open={takeOver.open} onClose={takeOver.close} material="opaque" ariaLabel={title}>
<SheetHeader title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{tour && (
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
{sourceText(tour, t)}
</p>
)}
{takeOver.done ? (
<p className="m-0 text-m-ink" style={fs(13)}>
{t('tours.collection.taken', { name: takeOver.done.tourName, trip: takeOver.done.tripTitle })}
</p>
) : (
<TakeOverForm takeOver={takeOver} />
)}
</div>
<SheetFooter>
{canRemove && tour && !takeOver.done && (
<button
type="button"
onClick={() => {
takeOver.close();
onRemove(tour);
}}
aria-label={t('tours.collection.remove')}
className="flex h-9 w-9 items-center justify-center rounded-full text-m-muted"
>
<Trash2 size={16} strokeWidth={2} />
</button>
)}
<span className="flex-1" />
{takeOver.done ? (
<PrimaryPill onClick={takeOver.openTrip}>
{t('tours.collection.openTrip')} <ArrowRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />
</PrimaryPill>
) : (
<>
<CancelPill onClick={takeOver.close} disabled={takeOver.taking}>
{t('common.cancel')}
</CancelPill>
<PrimaryPill onClick={() => void takeOver.confirm()} disabled={!takeOver.canConfirm}>
{takeOver.taking ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CopyPlus size={14} strokeWidth={2.2} />
)}
{t('tours.collection.add')}
</PrimaryPill>
</>
)}
</SheetFooter>
</MSheet>
);
}
/**
* LIB-03 on the phone: the whole tours the open list keeps, as its first
* rows, narrowed by the list's search like its places. Nothing while it keeps
* none, on "All saved", or while the Tours addon is off.
*/
export default function MCollTours({ search }: { search: string }) {
const { collectionId, canRemove } = useActiveCollectionTourList();
if (collectionId === null) return null;
return <MCollToursOf collectionId={collectionId} canRemove={canRemove} search={search} />;
}
function MCollToursOf({
collectionId,
canRemove,
search,
}: {
collectionId: number;
canRemove: boolean;
search: string;
}) {
const { t } = useTranslation();
const kept = useCollectionTours(collectionId);
const takeOver = useCollectionTourTakeOver(collectionId);
const shown = toursMatching(kept.tours, search);
if (shown.length === 0 && !takeOver.open && kept.removing === null) return null;
return (
<div data-testid="m-collection-tours">
{shown.map((tour) => (
<MCollTourRow key={tour.id} tour={tour} onOpen={() => takeOver.show(tour)} />
))}
<MCollTourSheet takeOver={takeOver} canRemove={canRemove} onRemove={kept.askRemove} />
<ConfirmDialog
isOpen={kept.removing !== null}
onClose={kept.cancelRemove}
onConfirm={() => void kept.confirmRemove()}
title={t('tours.collection.removeTitle')}
message={t('tours.collection.removeBody')}
confirmLabel={t('tours.collection.remove')}
cancelLabel={t('common.cancel')}
danger
/>
</div>
);
}
@@ -21,6 +21,7 @@ import MCollEditSheet from './MCollEditSheet'
import MCollLabelsSheet from './MCollLabelsSheet'
import MCollTripPickerSheet from './MCollTripPickerSheet'
import MCollListPickerSheet from './MCollListPickerSheet'
import MCollTours from './MCollTours'
import MCollInvites from './MCollInvites'
import { STATUS_SPEC } from './collectionsMobileModel'
import { CancelPill, PrimaryPill } from './MCollSheetKit'
@@ -412,6 +413,8 @@ export default function MCollections() {
</div>
{/* List view */}
{/* The whole tours the list keeps (LIB-03) open it, as rows like its places. */}
{c.view === 'list' && <MCollTours search={c.search} />}
{c.view === 'list' && (
c.placesLoading && !hasPlaces ? (
<div className="flex items-center justify-center py-16 text-m-faint">
+4 -2
View File
@@ -146,9 +146,12 @@ function CollectionsPageDesktop(): React.ReactElement {
t={t}
/>
) : null
// The whole tours the list keeps (LIB-03) open its list, as rows like its places.
const tourRows = isRealList ? <ActiveCollectionTours search={c.search} /> : null
const listColumn = (
<>
{filterBar}
{tourRows}
{c.visiblePlaces.length > 0
? listEl
: <EmptyState scene="search" title={t('collections.empty.noMatchTitle')} />}
@@ -161,6 +164,7 @@ function CollectionsPageDesktop(): React.ReactElement {
} else if (!hasPlaces) {
body = (
<div className="flex flex-col items-center">
{tourRows && <div className="w-full pb-2">{tourRows}</div>}
<EmptyState scene="collections" title={t('collections.empty.title')} className={canAddPlace ? 'pb-4' : undefined} />
{canAddPlace && (
<div className="flex flex-wrap items-center justify-center gap-2">
@@ -316,8 +320,6 @@ function CollectionsPageDesktop(): React.ReactElement {
</div>
)}
{/* The whole tours the list keeps (LIB-03), above its places. */}
{isRealList && <ActiveCollectionTours />}
{body}
</>
)}
+24
View File
@@ -276,6 +276,30 @@
.trek-dash .col-lrow-cat svg { flex-shrink: 0; }
.trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); }
/* A kept tour (LIB-03) is a row of the same list: its grade tile stands where
a place has its photo, its measures where a place has its labels, and its
way into a trip where a place has its status. The page's button reset strips
every button here, so the two buttons carry their whole look themselves. */
.trek-dash .col-tourrows { margin-bottom: 7px; }
.trek-dash .col-lrow-tile { display: grid; place-items: center; width: 40px; height: 40px; }
.trek-dash .col-lrow-tour .li .s { gap: 4px; }
.trek-dash .col-lrow-from { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin-inline-start: 6px; padding-inline-start: 8px; border-inline-start: 1px solid var(--line-2); }
.trek-dash .col-lrow-measures { display: none; align-items: center; gap: 5px; }
@media (min-width: 1100px) { .trek-dash .col-lrow-measures { display: inline-flex; } }
.trek-dash .col-lrow-act {
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px;
font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink);
background: var(--surface); border: 1px solid var(--line-2); box-shadow: var(--sh-xs);
transition: background .15s, color .15s, border-color .15s;
}
.trek-dash .col-lrow-act:hover, .trek-dash .col-lrow-act:focus-visible { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.trek-dash .col-lrow-act:focus-visible, .trek-dash .col-lrow-remove:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.trek-dash .col-lrow-remove {
display: grid; place-items: center; width: 30px; height: 30px; border-radius: 999px; color: var(--ink-3);
transition: opacity .15s, background .15s, color .15s;
}
.trek-dash .col-lrow-remove:hover { background: var(--surface-2); color: var(--ink); }
/* Per-collection label chips — list rows, filter bar and detail read view */
.trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); }
.trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }