feat(tours): keep whole tours in collections and take them into trips

A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
This commit is contained in:
Maurice
2026-10-20 13:15:42 +02:00
parent 3659297af1
commit 3fe34061fd
85 changed files with 6144 additions and 181 deletions
@@ -51,6 +51,7 @@ import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/tourActivityCompare';
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
@@ -665,6 +666,9 @@ export default function TourDetailDialog({
{analysis && place.id > 0 && (
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{place.id > 0 && (
<TourSaveToCollectionButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
<FooterSpacer />
{canEdit && onDelete && (
@@ -0,0 +1,392 @@
import type { CollectionTourPoint, CollectionTourTarget } from '@trek/shared';
import {
AlertTriangle,
ArrowRight,
CalendarDays,
CircleCheck,
CopyPlus,
Flag,
Loader2,
Play,
Spline,
Waypoints,
} from 'lucide-react';
import { useId, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDate } from '../../../utils/formatters';
import { WhiteBadge } from '../../Planner/planParts';
import { EditorField, INPUT } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourLinePreview, tourPreviewFacts } from '../library/tourPreviewParts';
import { RailFacts, TourNotice } from '../tourParts';
import { ChoiceList, ChoiceRow } from './choiceParts';
import type { CollectionTourTakeOver, PointsChoice } from './useCollectionTourTakeOver';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What a point's number says in its tooltip: its role, then its name where it has one. */
function pointLabel(point: CollectionTourPoint, stop: number, t: Translate): string {
const role =
point.role === 'start'
? t('tours.planner.start')
: point.role === 'end'
? t('tours.planner.end')
: t('tours.planner.waypointLabel', { n: stop });
return point.name ? `${role}: ${point.name}` : role;
}
/** Each point's number as the map counts them: stops only, a shaping point none. */
function stopNumbers(points: readonly CollectionTourPoint[]): Array<number | null> {
let stop = 0;
return points.map((point) => (point.shaping ? null : (stop += 1)));
}
/**
* The kept waypoints in route order, numbered as the map numbers them: a
* shaping point a small dot that does not count, the start and the end with
* their mark at the number's corner. Dimmed while they are left out.
*/
export function KeptPointsStrip({ points, dimmed }: { points: readonly CollectionTourPoint[]; dimmed: boolean }) {
const { t } = useTranslation();
const stops = stopNumbers(points);
return (
<ol
className={`m-0 flex list-none flex-wrap items-center gap-1.5 p-0 transition-opacity ${dimmed ? 'opacity-40' : ''}`}
data-testid="kept-points"
>
{points.map((point, index) => {
if (point.shaping)
return (
<li key={index} aria-label={t('tours.planner.shapingPoint')} className="grid h-6 w-3 place-items-center">
<span className="h-2 w-2 rounded-full bg-content-faint" />
</li>
);
const stop = stops[index] ?? 0;
const label = pointLabel(point, stop, t);
const Mark = point.role === 'start' ? Play : point.role === 'end' ? Flag : null;
return (
<li key={index}>
<Tooltip label={label}>
<span
aria-label={label}
className="relative grid h-6 w-6 place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
style={fs(11)}
>
{stop}
{Mark && (
<span className="absolute -end-1 -top-1 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm">
<Mark size={8} strokeWidth={2.6} />
</span>
)}
</span>
</Tooltip>
</li>
);
})}
</ol>
);
}
/** A trip's dates as badges, start then end, each only where the trip has it. */
function TripDates({ trip }: { trip: CollectionTourTarget }) {
const { language } = useTranslation();
const start = formatDate(trip.start_date, language);
const end = trip.end_date !== trip.start_date ? formatDate(trip.end_date, language) : null;
if (!start && !end) return null;
return (
<span className="flex flex-wrap items-center gap-1">
{start && (
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{start}
</WhiteBadge>
)}
{start && end && (
<ArrowRight size={10} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
)}
{end && <WhiteBadge>{end}</WhiteBadge>}
</span>
);
}
/** The trips the kept tour can go into; one the user may not add to stays in the list, dimmed, and says why. */
function TripChoices({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
if (takeOver.detailStatus !== 'ready') return null;
if (takeOver.trips.length === 0)
return (
<p className="m-0 py-3 text-center text-content-muted" style={fs(12.5, 'body')}>
{t('tours.collection.noTrips')}
</p>
);
return (
<ChoiceList label={t('tours.collection.intoTrip')}>
{takeOver.trips.map((trip) => (
<ChoiceRow
key={trip.id}
selected={takeOver.tripId === trip.id}
disabled={!trip.can_add}
disabledHint={t('tours.collection.noPermission')}
onChoose={() => takeOver.chooseTrip(trip.id)}
testId={`kept-tour-trip-${trip.id}`}
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{trip.title}
</span>
<TripDates trip={trip} />
</span>
</ChoiceRow>
))}
</ChoiceList>
);
}
/** One answer to whether the waypoints come along: an icon, a title, a short tooltip, raised once chosen. */
function PointsTile({
value,
selected,
disabled,
icon,
title,
hint,
onChoose,
}: {
value: PointsChoice;
selected: boolean;
disabled: boolean;
icon: ReactNode;
title: string;
hint: string;
onChoose: (value: PointsChoice) => void;
}) {
return (
<Tooltip label={hint}>
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => onChoose(value)}
data-testid={`kept-tour-points-${value}`}
className={`flex w-full min-w-0 items-center gap-2.5 rounded-[12px] border px-3 py-2.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50 ${selected ? 'border-transparent bg-surface-card shadow-sm ring-2 ring-accent' : 'border-edge-faint bg-surface-secondary enabled:hover:bg-surface-card'}`}
>
<span
className={`grid h-8 w-8 flex-none place-items-center rounded-full ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content-secondary'}`}
>
{icon}
</span>
<span className="min-w-0 flex-1 font-semibold text-content" style={fs(12.5, 'body')}>
{title}
</span>
</button>
</Tooltip>
);
}
/** The explicit choice about the waypoints; leaving them out is only there for a tour with a line. */
function PointsChoices({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
const { tour } = takeOver;
if (!tour) return null;
const lineOnlyHint = tour.has_line ? t('tours.collection.lineOnlyHint') : t('tours.collection.needsPoints');
return (
<div role="radiogroup" aria-label={t('tours.library.controlPoints')} className="grid grid-cols-2 gap-2">
<PointsTile
value="with"
selected={takeOver.points === 'with'}
disabled={tour.waypoint_count === 0 && tour.has_line}
icon={<Waypoints size={15} strokeWidth={2} />}
title={t('tours.collection.withPoints', { count: tour.waypoint_count })}
hint={t('tours.collection.withPointsHint')}
onChoose={takeOver.choosePoints}
/>
<PointsTile
value="without"
selected={takeOver.points === 'without'}
disabled={!tour.has_line}
icon={<Spline size={15} strokeWidth={2} />}
title={t('tours.collection.lineOnly')}
hint={lineOnlyHint}
onChoose={takeOver.choosePoints}
/>
</div>
);
}
/** The kept tour as it would come over: on a map, its facts and its waypoints. */
function KeptTourPreview({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const { tour, detail } = takeOver;
if (!tour) return null;
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="kept-tour-preview">
<TourLinePreview
line={detail?.line}
focusKey={String(tour.id)}
status={takeOver.detailStatus}
failedText={t('tours.library.previewFailed')}
/>
<RailFacts
facts={tourPreviewFacts(tour, tour.waypoint_count, unit, t, {
label: t('tours.collection.savedFrom'),
value: tour.source.tripTitle,
})}
/>
{detail && detail.points.length > 0 && (
<KeptPointsStrip points={detail.points} dimmed={takeOver.points === 'without'} />
)}
</div>
);
}
/** The last word once the tour is in the trip: where it is now. */
function TakenNotice({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
if (!takeOver.done) return null;
return (
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center" role="status">
<span className="grid h-14 w-14 place-items-center rounded-full bg-success-soft text-success ring-4 ring-surface-card">
<CircleCheck size={26} strokeWidth={2} />
</span>
<p className="m-0 max-w-[420px] font-semibold text-content" style={fs(14, 'subtitle')}>
{t('tours.collection.taken', { name: takeOver.done.tourName, trip: takeOver.done.tripTitle })}
</p>
</div>
);
}
function TakeOverFooter({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
if (takeOver.done)
return (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={takeOver.close}>{t('common.close')}</DialogButton>
<DialogButton
variant="primary"
onClick={takeOver.openTrip}
icon={<ArrowRight size={14} strokeWidth={2} className="rtl:rotate-180" />}
>
{t('tours.collection.openTrip')}
</DialogButton>
</DialogFooter>
);
return (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={takeOver.close} disabled={takeOver.taking}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="kept-tour-confirm"
disabled={!takeOver.canConfirm}
onClick={() => void takeOver.confirm()}
icon={
takeOver.taking ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<CopyPlus size={14} strokeWidth={2} />
)
}
>
{t('tours.collection.add')}
</DialogButton>
</DialogFooter>
);
}
/** Why the trips cannot be listed: offline, or failed with a retry. */
function DetailProblem({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
if (takeOver.detailStatus === 'offline') return <TourNotice>{t('tours.collection.offline')}</TourNotice>;
if (takeOver.detailStatus !== 'error') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.library.previewFailed')}
<button type="button" onClick={takeOver.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
}
/**
* LIB-03: the dialog that takes a tour kept in a collection into a trip. The
* kept tour on the left; on the right the trip, the choice about its
* waypoints and the name. The tour is made only by the button in its foot.
*/
export function CollectionTourTakeOverDialog({ takeOver }: { takeOver: CollectionTourTakeOver }) {
const { t } = useTranslation();
const labelId = useId();
const nameId = useId();
const { tour } = takeOver;
if (!tour) return null;
return (
<DialogShell
onClose={takeOver.close}
labelledBy={labelId}
width={takeOver.done ? 'narrow' : 'wide'}
align="top"
blocked={takeOver.taking}
header={
<DialogHeader
tile={
<DialogTile>
<CopyPlus size={20} strokeWidth={1.9} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={takeOver.close}
eyebrow={t('tours.collection.takeEyebrow')}
title={tour.name}
sub={takeOver.done ? undefined : t('tours.collection.takeSub')}
subWraps
/>
}
footer={<TakeOverFooter takeOver={takeOver} />}
>
{takeOver.done ? (
<TakenNotice takeOver={takeOver} />
) : (
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<KeptTourPreview takeOver={takeOver} />
<div className="flex min-w-0 flex-col gap-4">
<DialogSection label={t('tours.collection.intoTrip')}>
<DetailProblem takeOver={takeOver} />
<TripChoices takeOver={takeOver} />
</DialogSection>
<DialogSection label={t('tours.library.controlPoints')}>
<PointsChoices takeOver={takeOver} />
</DialogSection>
<EditorField label={t('tours.collection.nameInTrip')} htmlFor={nameId}>
<input
id={nameId}
className={INPUT}
value={takeOver.name}
maxLength={255}
onChange={(event) => takeOver.setName(event.target.value)}
/>
</EditorField>
</div>
</div>
)}
</DialogShell>
);
}
@@ -0,0 +1,165 @@
import type { CollectionTourItem } from '@trek/shared';
import { AlertTriangle, CopyPlus, 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 { CardAction, TourGradeTile, TourNotice } from '../tourParts';
import { CollectionTourTakeOverDialog } from './CollectionTourTakeOverDialog';
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;
}
/**
* 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.
*/
function KeptTourCard({
tour,
canRemove,
onTake,
onRemove,
}: {
tour: CollectionTourItem;
canRemove: boolean;
onTake: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const tile = { ...tour, has_waypoints: tour.waypoint_count > 0 };
return (
<article
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"
>
<div className="flex min-w-0 items-start gap-3">
<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>
</div>
{canRemove && (
<Tooltip label={t('tours.collection.remove')}>
<button
type="button"
onClick={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-0 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"
>
<Trash2 size={13} strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
<div className="flex flex-wrap gap-1.5">
<TourMeasurePills tour={tour} testPrefix="kept-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>
);
}
/** What stands above the cards while they cannot be shown: offline, or failed with a retry. */
function SectionProblem({ kept }: { kept: CollectionTours }) {
const { t } = useTranslation();
if (kept.status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.collection.offline')}</TourNotice>;
if (kept.status !== 'error') return null;
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.collection.toursFailed')}
<button type="button" onClick={kept.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
}
/**
* 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.
*/
export function CollectionToursSection({ collectionId, canRemove }: { collectionId: number; canRemove: boolean }) {
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;
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>
<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>
)}
<CollectionTourTakeOverDialog takeOver={takeOver} />
<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
/>
</section>
);
}
@@ -0,0 +1,182 @@
import { AlertTriangle, Bookmark, BookmarkPlus, Loader2, MapPin, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { WhiteBadge } from '../../Planner/planParts';
import { EditorField, INPUT } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { TourNotice } from '../tourParts';
import { ChoiceList, ChoiceRow } from './choiceParts';
import type { CollectionChoice, SaveTourToCollection } from './useSaveTourToCollection';
/** One list to keep the tour in: its colour, its name and how many places it holds. */
function ListRow({
choice,
selected,
onChoose,
}: {
choice: CollectionChoice;
selected: boolean;
onChoose: () => void;
}) {
const { t } = useTranslation();
const { collection, canAdd } = choice;
return (
<ChoiceRow
selected={selected}
disabled={!canAdd}
disabledHint={t('tours.collection.readOnly')}
onChoose={onChoose}
testId={`keep-tour-list-${collection.id}`}
>
{/* The list's own colour, as the collections rail shows it. */}
<span
className="h-2.5 w-2.5 flex-none rounded-full"
style={{ background: collection.color || 'var(--accent)' }}
/>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{collection.name}
</span>
{collection.place_count != null && (
<WhiteBadge icon={<MapPin size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{collection.place_count}
</WhiteBadge>
)}
</ChoiceRow>
);
}
/** The lists, or what stands in their place: loading, offline, failed with a retry, none yet. */
function Lists({ keep }: { keep: SaveTourToCollection }) {
const { t } = useTranslation();
if (keep.status === 'loading')
return (
<p role="status" className="m-0 flex items-center justify-center py-6 text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
<span className="sr-only">{t('common.loading')}</span>
</p>
);
if (keep.status === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.collection.offline')}</TourNotice>;
if (keep.status === 'error')
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.collection.listsFailed')}
<button type="button" onClick={keep.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
if (keep.choices.length === 0)
return (
<div className="flex flex-col items-center gap-3 px-4 py-5 text-center">
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-surface-secondary text-content-faint">
<Bookmark size={20} strokeWidth={1.9} />
</span>
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>
{t('tours.collection.noLists')}
</p>
<DialogButton onClick={keep.openCollections} icon={<Bookmark size={14} strokeWidth={2} />}>
{t('tours.collection.openCollections')}
</DialogButton>
</div>
);
return (
<ChoiceList label={t('tours.collection.list')} tall>
{keep.choices.map((choice) => (
<ListRow
key={choice.collection.id}
choice={choice}
selected={keep.listId === choice.collection.id}
onChoose={() => keep.choose(choice.collection.id)}
/>
))}
</ChoiceList>
);
}
/**
* LIB-03: the dialog that keeps a saved tour in a collection. It names the
* tour, lets the user pick a list they may add to and the name it is kept
* under, and keeps it only through the button in its foot.
*/
export function SaveTourToCollectionDialog({ keep, tourName }: { keep: SaveTourToCollection; tourName: string }) {
const { t } = useTranslation();
const labelId = useId();
const nameId = useId();
if (!keep.open) return null;
return (
<DialogShell
onClose={keep.close}
labelledBy={labelId}
width="narrow"
align="top"
blocked={keep.saving}
header={
<DialogHeader
tile={
<DialogTile>
<BookmarkPlus size={20} strokeWidth={1.9} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={keep.close}
eyebrow={t('tours.collection.save')}
title={tourName}
sub={t('tours.collection.saveSub')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={keep.close} disabled={keep.saving}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="keep-tour-confirm"
disabled={!keep.canConfirm}
onClick={() => void keep.confirm()}
icon={
keep.saving ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<BookmarkPlus size={14} strokeWidth={2} />
)
}
>
{t('tours.collection.keep')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('tours.collection.list')}>
<Lists keep={keep} />
</DialogSection>
{keep.status === 'ready' && keep.choices.length > 0 && (
<EditorField label={t('tours.collection.nameThere')} htmlFor={nameId}>
<input
id={nameId}
className={INPUT}
value={keep.name}
maxLength={255}
onChange={(event) => keep.setName(event.target.value)}
/>
</EditorField>
)}
</DialogShell>
);
}
@@ -0,0 +1,68 @@
import { BookmarkPlus } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { BarButton } from '../../Planner/planParts';
import { DialogButton } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { SaveTourToCollectionDialog } from './SaveTourToCollectionDialog';
import { useSaveTourToCollection, type TourSaveTarget } from './useSaveTourToCollection';
/** The footer button with its dialog, once the Collections addon is known to be on. */
function KeepButton({ target }: { target: TourSaveTarget }) {
const { t } = useTranslation();
const keep = useSaveTourToCollection(target);
return (
<>
<Tooltip label={t('tours.collection.saveHint')}>
<span className="inline-flex">
<DialogButton
onClick={keep.show}
aria-label={t('tours.collection.save')}
data-testid="tour-keep-in-collection"
icon={<BookmarkPlus size={14} strokeWidth={2} />}
/>
</span>
</Tooltip>
<SaveTourToCollectionDialog keep={keep} tourName={target.name} />
</>
);
}
/** The head band button with its dialog, waiting for the save while the tour has unsaved changes. */
function KeepBarButton({ target, unsaved }: { target: TourSaveTarget; unsaved: boolean }) {
const { t } = useTranslation();
const keep = useSaveTourToCollection(target);
return (
<>
<BarButton
label={t(unsaved ? 'tours.collection.saveFirst' : 'tours.collection.save')}
tooltip={unsaved ? undefined : t('tours.collection.saveHint')}
onClick={keep.show}
disabled={unsaved}
testId="tour-bar-keep-in-collection"
>
<BookmarkPlus size={15} strokeWidth={2} />
</BarButton>
<SaveTourToCollectionDialog keep={keep} tourName={target.name} />
</>
);
}
/**
* LIB-03 in a tour's footer: keeps the saved tour in a collection, its own
* action beside the GPX export, only while the Collections addon is on. The
* tooltip says that what is kept is an independent copy.
*/
export function TourSaveToCollectionButton({ target }: { target: TourSaveTarget }) {
const enabled = useAddonStore((state) => state.isEnabled('collections'));
return enabled ? <KeepButton target={target} /> : null;
}
/**
* The same action in the planner's head. A tour with unsaved changes would
* be kept without them, so the button waits for the save and says so.
*/
export function TourSaveToCollectionBarButton({ target, unsaved }: { target: TourSaveTarget; unsaved: boolean }) {
const enabled = useAddonStore((state) => state.isEnabled('collections'));
return enabled ? <KeepBarButton target={target} unsaved={unsaved} /> : null;
}
@@ -0,0 +1,66 @@
import { Check, Lock } from 'lucide-react';
import type { ReactNode } from 'react';
import { Tooltip } from '../../shared/Tooltip';
/**
* A panel of rows of which one is chosen, the way the collection pickers lay
* them out: grey, rounded, scrolling once it holds more than fits.
*/
export function ChoiceList({ label, tall = false, children }: { label: string; tall?: boolean; children: ReactNode }) {
return (
<div
role="radiogroup"
aria-label={label}
className={`flex flex-col gap-0.5 overflow-y-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5 ${tall ? 'max-h-[300px]' : 'max-h-[220px]'}`}
>
{children}
</div>
);
}
/**
* One row of a ChoiceList: raised and ticked once chosen. A row that cannot
* be chosen stays in the list, dimmed with a lock, and its tooltip says why.
*/
export function ChoiceRow({
selected,
disabled,
disabledHint,
onChoose,
testId,
children,
}: {
selected: boolean;
disabled: boolean;
disabledHint: string;
onChoose: () => void;
testId: string;
children: ReactNode;
}) {
const row = (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={onChoose}
data-testid={testId}
className={`flex min-h-[44px] w-full min-w-0 items-center gap-3 rounded-[10px] px-3 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50 ${selected ? 'bg-surface-card shadow-sm ring-1 ring-inset ring-edge' : 'enabled:hover:bg-surface-card'}`}
>
{children}
{disabled && <Lock size={13} strokeWidth={2} className="flex-none text-content-faint" />}
{selected && (
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-accent text-accent-text">
<Check size={12} strokeWidth={2.6} />
</span>
)}
</button>
);
return disabled ? (
<Tooltip label={disabledHint}>
<span className="block">{row}</span>
</Tooltip>
) : (
row
);
}
@@ -0,0 +1,361 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { Collection, CollectionTourDetailResponse, CollectionTourItem, TourCreateResponse } from '@trek/shared';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { collectionToursRepo } from '../../../repo/collectionToursRepo';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useCollectionTourStore } from '../../../store/collectionTourStore';
import { CollectionTourTakeOverDialog, KeptPointsStrip } from './CollectionTourTakeOverDialog';
import { CollectionToursSection } from './CollectionToursSection';
import { SaveTourToCollectionDialog } from './SaveTourToCollectionDialog';
import { TourSaveToCollectionBarButton, TourSaveToCollectionButton } from './TourSaveToCollectionButton';
import { useCollectionTourTakeOver } from './useCollectionTourTakeOver';
import { useSaveTourToCollection } from './useSaveTourToCollection';
// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-013
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
<div data-testid="kept-map" data-points={route[0]?.length ?? 0} />
),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const LISTS = [
{
id: 1,
owner_id: 9,
name: 'Shared',
is_owner: false,
members: [{ user_id: 7, username: 'me', status: 'accepted', role: 'viewer' }],
},
{ id: 2, owner_id: 7, name: 'Hikes', is_owner: true, place_count: 4, color: '#123456' },
{ id: 3, owner_id: 7, name: 'Later', is_owner: true },
] as Collection[];
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: 'via', name: 'Hut', shaping: false },
{ role: 'via', name: null, shaping: true },
{ 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 },
],
};
function wrapper({ children }: { children: ReactNode }) {
return (
<MemoryRouter>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
}
const target = { tripId: '5', placeId: 11, name: 'Ridge' };
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
useAuthStore.setState({ user: { id: 7 } as never });
useAddonStore.setState({
addons: [
{ id: 'tours', enabled: true },
{ id: 'collections', enabled: true },
] as never,
});
useCollectionTourStore.getState().clear();
vi.spyOn(collectionToursRepo, 'lists').mockResolvedValue(LISTS);
vi.spyOn(collectionToursRepo, 'detail').mockResolvedValue(DETAIL);
});
afterEach(() => vi.restoreAllMocks());
describe('useSaveTourToCollection', () => {
it('FE-COLLECTION-TOURS-001: offers every list, picks the first one it may add to and keeps the tour on confirm', async () => {
const save = vi.spyOn(collectionToursRepo, 'save').mockResolvedValue({ tour: kept({ name: 'My ridge' }) });
const { result } = renderHook(() => useSaveTourToCollection(target), { wrapper });
act(() => result.current.show());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.choices.map((choice) => [choice.collection.id, choice.canAdd])).toEqual([
[1, false],
[2, true],
[3, true],
]);
expect(result.current.listId).toBe(2);
act(() => result.current.choose(1));
expect(result.current.listId).toBe(2);
act(() => result.current.choose(3));
act(() => result.current.setName(' My ridge '));
await act(() => result.current.confirm());
expect(save).toHaveBeenCalledWith(3, { source_trip_id: 5, source_place_id: 11, name: 'My ridge' });
expect(toast.success).toHaveBeenCalledWith('“My ridge” is now kept in “Later”.');
expect(result.current.open).toBe(false);
});
it('FE-COLLECTION-TOURS-002: a failed keep leaves the dialog open, and the lists can be read again', async () => {
const save = vi
.spyOn(collectionToursRepo, 'save')
.mockRejectedValueOnce(new OnlineOnlyError())
.mockRejectedValueOnce(new Error('500'));
vi.mocked(collectionToursRepo.lists).mockRejectedValueOnce(new Error('500'));
const { result } = renderHook(() => useSaveTourToCollection(target), { wrapper });
act(() => result.current.show());
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.canConfirm).toBe(false);
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
await act(() => result.current.confirm());
expect(toast.error).toHaveBeenLastCalledWith('Collections need a connection.');
await act(() => result.current.confirm());
expect(toast.error).toHaveBeenLastCalledWith('The tour could not be kept.');
expect(save).toHaveBeenCalledTimes(2);
expect(result.current.open).toBe(true);
act(() => result.current.setName(' '));
expect(result.current.canConfirm).toBe(false);
});
});
describe('SaveTourToCollectionDialog', () => {
function Harness() {
const keep = useSaveTourToCollection(target);
return (
<>
<button type="button" onClick={keep.show}>
open
</button>
<SaveTourToCollectionDialog keep={keep} tourName={target.name} />
</>
);
}
it('FE-COLLECTION-TOURS-003: shows the lists, the one it may only read dimmed, and keeps through the foot', async () => {
const save = vi.spyOn(collectionToursRepo, 'save').mockResolvedValue({ tour: kept() });
render(<Harness />);
fireEvent.click(screen.getByText('open'));
const shared = await screen.findByTestId('keep-tour-list-1');
expect(shared).toBeDisabled();
expect(screen.getByTestId('keep-tour-list-2')).toHaveAttribute('aria-checked', 'true');
expect(within(screen.getByTestId('keep-tour-list-2')).getByText('4')).toBeInTheDocument();
expect(screen.getByDisplayValue('Ridge')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('keep-tour-confirm'));
await waitFor(() => expect(save).toHaveBeenCalled());
});
it('FE-COLLECTION-TOURS-004: without a list it says so and leads to the collections', async () => {
vi.mocked(collectionToursRepo.lists).mockResolvedValue([]);
render(<Harness />);
fireEvent.click(screen.getByText('open'));
expect(await screen.findByText('You have no collection yet.')).toBeInTheDocument();
fireEvent.click(screen.getByText('Open collections'));
expect(screen.queryByText('You have no collection yet.')).toBeNull();
});
});
describe('TourSaveToCollectionButton', () => {
it('FE-COLLECTION-TOURS-005: is there only with the Collections addon, and waits for the save in the planner', async () => {
const { unmount } = render(<TourSaveToCollectionButton target={target} />);
fireEvent.click(screen.getByTestId('tour-keep-in-collection'));
expect(await screen.findByTestId('keep-tour-list-2')).toBeInTheDocument();
unmount();
render(<TourSaveToCollectionBarButton target={target} unsaved />);
expect(screen.getByTestId('tour-bar-keep-in-collection')).toBeDisabled();
expect(screen.getByTestId('tour-bar-keep-in-collection')).toHaveAccessibleName(
'Save the tour first to keep it in a collection'
);
});
it('FE-COLLECTION-TOURS-006: shows nothing while the Collections addon is off', () => {
useAddonStore.setState({ addons: [{ id: 'tours', enabled: true }] as never });
render(
<>
<TourSaveToCollectionButton target={target} />
<TourSaveToCollectionBarButton target={target} unsaved={false} />
</>
);
expect(screen.queryByTestId('tour-keep-in-collection')).toBeNull();
expect(screen.queryByTestId('tour-bar-keep-in-collection')).toBeNull();
});
});
describe('useCollectionTourTakeOver', () => {
const answer = { tour: { place_id: 9, name: 'Ridge' }, waypoints: [] } as unknown as TourCreateResponse;
it('FE-COLLECTION-TOURS-007: takes it into a trip it may add to, only once the waypoints are decided, then opens that trip', async () => {
const takeOver = vi.spyOn(collectionToursRepo, 'takeOver').mockResolvedValue(answer);
const { result } = renderHook(() => ({ flow: useCollectionTourTakeOver(3), location: useLocation() }), {
wrapper,
});
act(() => result.current.flow.show(kept()));
await waitFor(() => expect(result.current.flow.detailStatus).toBe('ready'));
expect(result.current.flow.points).toBeNull();
act(() => result.current.flow.chooseTrip(7));
expect(result.current.flow.tripId).toBeNull();
act(() => result.current.flow.chooseTrip(6));
expect(result.current.flow.canConfirm).toBe(false);
act(() => result.current.flow.choosePoints('without'));
expect(result.current.flow.canConfirm).toBe(true);
await act(() => result.current.flow.confirm());
expect(takeOver).toHaveBeenCalledWith(6, {
collection_id: 3,
collection_tour_id: 4,
include_waypoints: false,
name: 'Ridge',
});
expect(result.current.flow.done).toEqual({ tripId: 6, tripTitle: 'Coast', tourName: 'Ridge' });
expect(toast.success).toHaveBeenCalledWith('“Ridge” is now a tour of “Coast”.');
act(() => result.current.flow.openTrip());
expect(result.current.location.pathname).toBe('/trips/6');
expect(result.current.flow.open).toBe(false);
});
it('FE-COLLECTION-TOURS-008: a tour without a line brings its waypoints, and a failure keeps the dialog', async () => {
vi.spyOn(collectionToursRepo, 'takeOver')
.mockRejectedValueOnce(new Error('500'))
.mockRejectedValueOnce(new OnlineOnlyError());
const { result } = renderHook(() => useCollectionTourTakeOver(3), { wrapper });
act(() => result.current.show(kept({ has_line: false })));
expect(result.current.points).toBe('with');
act(() => result.current.choosePoints('without'));
expect(result.current.points).toBe('with');
await waitFor(() => expect(result.current.detailStatus).toBe('ready'));
act(() => result.current.chooseTrip(6));
await act(() => result.current.confirm());
expect(toast.error).toHaveBeenLastCalledWith('The tour could not be added to the trip.');
await act(() => result.current.confirm());
expect(toast.error).toHaveBeenLastCalledWith('Collections need a connection.');
expect(result.current.done).toBeNull();
expect(result.current.open).toBe(true);
act(() => result.current.close());
expect(result.current.open).toBe(false);
});
it('FE-COLLECTION-TOURS-009: a preview that cannot be read says so and can be asked again', async () => {
vi.mocked(collectionToursRepo.detail).mockRejectedValueOnce(new OnlineOnlyError());
const { result } = renderHook(() => useCollectionTourTakeOver(3), { wrapper });
act(() => result.current.show(kept()));
await waitFor(() => expect(result.current.detailStatus).toBe('offline'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.detailStatus).toBe('ready'));
});
});
describe('CollectionTourTakeOverDialog', () => {
function Harness() {
const flow = useCollectionTourTakeOver(3);
return (
<>
<button type="button" onClick={() => flow.show(kept())}>
open
</button>
<CollectionTourTakeOverDialog takeOver={flow} />
</>
);
}
it('FE-COLLECTION-TOURS-010: shows the kept tour, the trips and the choice, and says where the new tour is', async () => {
vi.spyOn(collectionToursRepo, 'takeOver').mockResolvedValue({
tour: { place_id: 9, name: 'Ridge' },
waypoints: [],
} as unknown as TourCreateResponse);
render(<Harness />);
fireEvent.click(screen.getByText('open'));
const preview = screen.getByTestId('kept-tour-preview');
expect(await within(preview).findByTestId('kept-map')).toHaveAttribute('data-points', '2');
expect(preview).toHaveTextContent('Alps');
expect(screen.getByTestId('kept-tour-trip-7')).toBeDisabled();
expect(screen.getByTestId('kept-tour-confirm')).toBeDisabled();
fireEvent.click(screen.getByTestId('kept-tour-trip-6'));
fireEvent.click(screen.getByTestId('kept-tour-points-with'));
expect(screen.getByTestId('kept-tour-points-with')).toHaveAttribute('aria-checked', 'true');
fireEvent.click(screen.getByTestId('kept-tour-confirm'));
expect(await screen.findByRole('status')).toHaveTextContent('“Ridge” is now a tour of “Coast”.');
expect(screen.getByText('Open trip')).toBeInTheDocument();
});
it('FE-COLLECTION-TOURS-011: numbers the waypoints as the map does, a shaping point a dot, and dims them when left out', () => {
const { rerender } = render(<KeptPointsStrip points={DETAIL.points} dimmed={false} />);
const strip = screen.getByTestId('kept-points');
expect(within(strip).getByLabelText('Start')).toHaveTextContent('1');
expect(within(strip).getByLabelText('Waypoint 2: Hut')).toHaveTextContent('2');
expect(within(strip).getByLabelText('Shaping point')).toBeInTheDocument();
expect(within(strip).getByLabelText('End')).toHaveTextContent('3');
expect(strip).not.toHaveClass('opacity-40');
rerender(<KeptPointsStrip points={DETAIL.points} dimmed />);
expect(screen.getByTestId('kept-points')).toHaveClass('opacity-40');
});
});
describe('CollectionToursSection', () => {
it('FE-COLLECTION-TOURS-012: shows the kept tours as cards, removes one after asking, and nothing while there is none', async () => {
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [kept()] });
const remove = vi.spyOn(collectionToursRepo, 'remove').mockResolvedValue(undefined);
render(<CollectionToursSection collectionId={3} canRemove />);
const card = await screen.findByTestId('kept-tour-4');
expect(card).toHaveTextContent('Ridge');
expect(card).toHaveTextContent('Alps');
expect(within(card).getByTestId('kept-tour-distance')).toHaveTextContent('8.2 km');
expect(within(card).getByLabelText('4 waypoints')).toBeInTheDocument();
fireEvent.click(within(card).getByTestId('kept-tour-remove-4'));
// The card's own button and the question's confirm share the name; the question's comes last.
const removeButtons = await screen.findAllByRole('button', { name: 'Remove from the collection' });
fireEvent.click(removeButtons[removeButtons.length - 1] as HTMLElement);
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, 4));
expect(toast.success).toHaveBeenCalledWith('The tour was removed from the collection.');
await waitFor(() => expect(screen.queryByTestId('collection-tours')).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()] });
const { unmount } = render(<CollectionToursSection collectionId={3} canRemove={false} />);
expect(await screen.findByText('The tours of this list could not be loaded.')).toBeInTheDocument();
fireEvent.click(screen.getByText('Retry'));
const card = await screen.findByTestId('kept-tour-4');
expect(within(card).queryByTestId('kept-tour-remove-4')).toBeNull();
fireEvent.click(within(card).getByText('Add to a trip'));
expect(await screen.findByTestId('kept-tour-preview')).toBeInTheDocument();
unmount();
useAddonStore.setState({ addons: [{ id: 'collections', enabled: true }] as never });
render(<CollectionToursSection collectionId={3} canRemove />);
expect(screen.queryByTestId('collection-tours')).toBeNull();
});
});
@@ -0,0 +1,139 @@
import type { CollectionTourDetailResponse, CollectionTourItem } from '@trek/shared';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { collectionToursRepo } from '../../../repo/collectionToursRepo';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useToast } from '../../shared/Toast';
/** Whether the kept tour's waypoints come along; nothing until the user says. */
export type PointsChoice = 'with' | 'without';
type DetailStatus = 'loading' | 'ready' | 'error' | 'offline';
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
interface Answer {
key: string;
status: Exclude<DetailStatus, 'loading'>;
data?: CollectionTourDetailResponse;
}
/** What the take-over made, for the dialog's last word. */
interface TakenOver {
tripId: number;
tripTitle: string;
tourName: string;
}
const failure = (err: unknown): 'error' | 'offline' => (err instanceof OnlineOnlyError ? 'offline' : 'error');
/** The kept tour's preview and the trips it could go into, read anew whenever another tour is chosen. */
function useKeptTourDetail(collectionId: number, tour: CollectionTourItem | null, attempt: number) {
const [answer, setAnswer] = useState<Answer | null>(null);
const tourId = tour?.id ?? null;
const key = `${collectionId}:${tourId ?? ''}:${attempt}`;
useEffect(() => {
if (tourId === null) return undefined;
const controller = new AbortController();
collectionToursRepo
.detail(collectionId, tourId, controller.signal)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
return () => controller.abort();
}, [collectionId, tourId, key]);
const status: DetailStatus | null = tourId === null ? null : answer?.key === key ? answer.status : 'loading';
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
}
/**
* LIB-03: taking a tour kept in a collection into a trip. The dialog shows
* the kept tour on a map with its facts and waypoints, lists the trips the
* user can open (those they may not add to dimmed), and asks outright whether
* the waypoints come along. A tour without a line cannot leave them behind.
* Nothing is written before the confirm; afterwards the dialog says where the
* new tour is and offers to open that trip.
*/
export function useCollectionTourTakeOver(collectionId: number) {
const { t } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const [tour, setTour] = useState<CollectionTourItem | null>(null);
const [tripId, setTripId] = useState<number | null>(null);
const [points, setPoints] = useState<PointsChoice | null>(null);
const [name, setName] = useState('');
const [taking, setTaking] = useState(false);
const [done, setDone] = useState<TakenOver | null>(null);
const [attempt, setAttempt] = useState(0);
const detail = useKeptTourDetail(collectionId, tour, attempt);
const trips = detail.data?.trips ?? [];
const trip = trips.find((candidate) => candidate.id === tripId && candidate.can_add) ?? null;
const show = (chosen: CollectionTourItem) => {
setTour(chosen);
setName(chosen.name);
setTripId(null);
// A tour without a line has only one honest answer, and it is given for the user to see.
setPoints(chosen.has_line ? null : 'with');
setDone(null);
};
const close = () => {
if (!taking) setTour(null);
};
const chooseTrip = (id: number) => {
if (trips.some((candidate) => candidate.id === id && candidate.can_add)) setTripId(id);
};
const choosePoints = (choice: PointsChoice) => {
if (choice === 'without' && !tour?.has_line) return;
setPoints(choice);
};
const confirm = async () => {
const chosenName = name.trim();
if (!tour || !trip || !points || !chosenName || taking) return;
setTaking(true);
try {
const result = await collectionToursRepo.takeOver(trip.id, {
collection_id: collectionId,
collection_tour_id: tour.id,
include_waypoints: points === 'with',
name: chosenName,
});
setDone({ tripId: trip.id, tripTitle: trip.title, tourName: result.tour.name });
toast.success(t('tours.collection.taken', { name: result.tour.name, trip: trip.title }));
} catch (err: unknown) {
toast.error(t(failure(err) === 'offline' ? 'tours.collection.offline' : 'tours.collection.takeFailed'));
} finally {
setTaking(false);
}
};
return {
open: tour !== null,
tour,
show,
close,
detailStatus: detail.status,
detail: detail.data,
trips,
tripId: trip?.id ?? null,
chooseTrip,
points,
choosePoints,
name,
setName,
taking,
done,
canConfirm: !!trip && !!points && name.trim().length > 0 && !taking && !done,
confirm,
retry: () => setAttempt((value) => value + 1),
openTrip: () => {
if (!done) return;
setTour(null);
navigate(`/trips/${done.tripId}`);
},
};
}
export type CollectionTourTakeOver = ReturnType<typeof useCollectionTourTakeOver>;
@@ -0,0 +1,56 @@
import type { CollectionTourItem } from '@trek/shared';
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../../i18n';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useAddonStore } from '../../../store/addonStore';
import { useCollectionTourStore, watchCollectionTours } from '../../../store/collectionTourStore';
import { useToast } from '../../shared/Toast';
/**
* LIB-03: the tours a collection keeps, for the collection's page. Read when
* the collection opens and kept current while its members keep or remove one;
* nothing at all while the Tours addon is off. Removing asks first and says
* how it went.
*/
export function useCollectionTours(collectionId: number) {
const { t } = useTranslation();
const toast = useToast();
const enabled = useAddonStore((state) => state.isEnabled('tours'));
const { tours, status, shownFor } = useCollectionTourStore(
useShallow((state) => ({ tours: state.tours, status: state.status, shownFor: state.collectionId }))
);
const [removing, setRemoving] = useState<CollectionTourItem | null>(null);
useEffect(() => {
if (!enabled) return undefined;
void useCollectionTourStore.getState().load(collectionId);
return watchCollectionTours();
}, [collectionId, enabled]);
const confirmRemove = async () => {
const tour = removing;
setRemoving(null);
if (!tour) return;
try {
await useCollectionTourStore.getState().remove(collectionId, tour.id);
toast.success(t('tours.collection.removed'));
} catch (err: unknown) {
toast.error(t(err instanceof OnlineOnlyError ? 'tours.collection.offline' : 'tours.collection.removeFailed'));
}
};
const current = shownFor === collectionId;
return {
enabled,
tours: enabled && current ? tours : [],
status: enabled && current ? status : 'idle',
retry: () => void useCollectionTourStore.getState().load(collectionId),
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
confirmRemove,
};
}
export type CollectionTours = ReturnType<typeof useCollectionTours>;
@@ -0,0 +1,134 @@
import type { Collection } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { collectionToursRepo } from '../../../repo/collectionToursRepo';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useAuthStore } from '../../../store/authStore';
import { canAddToCollection } from '../../../utils/collectionAccess';
import { useToast } from '../../shared/Toast';
/** The saved tour of a trip that is to be kept. */
export interface TourSaveTarget {
tripId: number | string;
placeId: number;
name: string;
}
/** A list as the picker shows it: every list the user has, the ones they may not add to dimmed. */
export interface CollectionChoice {
collection: Collection;
canAdd: boolean;
}
type ListStatus = 'loading' | 'ready' | 'error' | 'offline';
/** The lists with the opening they were read for, so a newer opening reads as loading, never as an older answer. */
interface ListsAnswer {
key: string;
status: Exclude<ListStatus, 'loading'>;
lists: Collection[];
}
const failure = (err: unknown): 'error' | 'offline' => (err instanceof OnlineOnlyError ? 'offline' : 'error');
/**
* LIB-03: keeping a saved tour in a collection. Every opening reads the
* user's lists afresh and preselects the first one they may add to; the tour
* is kept only on an explicit confirm, under the name in the field, as an
* independent copy. Closing writes nothing.
*/
export function useSaveTourToCollection(target: TourSaveTarget) {
const { t } = useTranslation();
const toast = useToast();
const navigate = useNavigate();
const userId = useAuthStore((state) => state.user?.id);
const [opening, setOpening] = useState(0);
const [open, setOpen] = useState(false);
const [answer, setAnswer] = useState<ListsAnswer | null>(null);
const [listId, setListId] = useState<number | null>(null);
const [name, setName] = useState(target.name);
const [saving, setSaving] = useState(false);
const key = String(opening);
useEffect(() => {
if (!open) return undefined;
let cancelled = false;
collectionToursRepo
.lists()
.then((lists) => {
if (cancelled) return;
setAnswer({ key, status: 'ready', lists });
const first = lists.find((collection) => canAddToCollection(collection, userId));
setListId((current) => current ?? first?.id ?? null);
})
.catch((err: unknown) => {
if (!cancelled) setAnswer({ key, status: failure(err), lists: [] });
});
return () => {
cancelled = true;
};
}, [open, key, userId]);
const status: ListStatus = answer?.key === key ? answer.status : 'loading';
const lists = answer?.key === key ? answer.lists : null;
const choices = useMemo<CollectionChoice[]>(
() => (lists ?? []).map((collection) => ({ collection, canAdd: canAddToCollection(collection, userId) })),
[lists, userId]
);
const show = useCallback(() => {
setName(target.name);
setListId(null);
setOpening((value) => value + 1);
setOpen(true);
}, [target.name]);
const close = () => {
if (!saving) setOpen(false);
};
const choose = (id: number) => {
if (choices.some((choice) => choice.collection.id === id && choice.canAdd)) setListId(id);
};
const confirm = async () => {
const chosenName = name.trim();
const list = choices.find((choice) => choice.collection.id === listId && choice.canAdd)?.collection;
if (!list || !chosenName || saving) return;
setSaving(true);
try {
const { tour } = await collectionToursRepo.save(list.id, {
source_trip_id: Number(target.tripId),
source_place_id: target.placeId,
name: chosenName,
});
setOpen(false);
toast.success(t('tours.collection.kept', { name: tour.name, list: list.name }));
} catch (err: unknown) {
toast.error(t(failure(err) === 'offline' ? 'tours.collection.offline' : 'tours.collection.keepFailed'));
} finally {
setSaving(false);
}
};
return {
open,
show,
close,
status,
choices,
listId,
choose,
name,
setName,
saving,
canConfirm: status === 'ready' && listId !== null && name.trim().length > 0 && !saving,
confirm,
retry: () => setOpening((value) => value + 1),
openCollections: () => {
setOpen(false);
navigate('/collections');
},
};
}
export type SaveTourToCollection = ReturnType<typeof useSaveTourToCollection>;
@@ -1,12 +1,8 @@
import { importableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
import { useTileUrl } from '../../../hooks/useTileUrl';
import { AlertTriangle, CopyPlus, Library, Loader2, Search, WifiOff } from 'lucide-react';
import { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
import {
DialogButton,
@@ -18,49 +14,15 @@ import {
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
import { RailFacts, TourNotice } from '../tourParts';
import { TourLinePreview, TourMeasurePills, tourPreviewFacts } from './tourPreviewParts';
import { type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
const ALL = 'all';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: TourLibraryItem, 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);
}
/** One tour of the list: its name, the trip it is in, its measures. */
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<li>
<button
@@ -82,15 +44,7 @@ function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selec
{tour.trip.title}
</span>
<span className="flex flex-wrap gap-1.5">
{measureBadges(tour, unit, t).map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-library-${badge.key}`}
/>
))}
<TourMeasurePills tour={tour} testPrefix="tour-library" />
</span>
</button>
</li>
@@ -181,38 +135,11 @@ function ListFilters({ library }: { library: TourLibrary }) {
);
}
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
const total = plannedTotalText(tourPlannedTimes(tour), t);
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** The chosen tour: on a map, its facts, and the name the copy gets. */
function Preview({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const tileUrl = useTileUrl(OFM_POSITRON);
const nameId = useId();
const line = library.preview?.line;
const route = useMemo(
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
() =>
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
[line]
);
const { selected } = library;
if (!selected)
return (
@@ -224,32 +151,18 @@ function Preview({ library }: { library: TourLibrary }) {
);
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
{route.length > 0 && (
<MapViewAuto
places={[]}
route={route}
tileUrl={tileUrl}
focusPoints={route[0]}
focusKey={String(selected.place_id)}
followSelection={false}
/>
)}
{library.previewStatus === 'loading' && (
<span className="absolute inset-0 grid place-items-center text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
</span>
)}
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
<span
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
style={fs(12, 'body')}
>
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
</span>
)}
</div>
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
<TourLinePreview
line={library.preview?.line}
focusKey={String(selected.place_id)}
status={library.previewStatus}
failedText={t('tours.library.previewFailed')}
/>
<RailFacts
facts={tourPreviewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t, {
label: t('tours.library.fromTrip'),
value: selected.trip.title,
})}
/>
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
<input
id={nameId}
@@ -0,0 +1,165 @@
import type { TourLibraryItem } from '@trek/shared';
import { ArrowUp, Clock, Loader2, Ruler } 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 type { TourMetricField } from '../tourParts';
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
/** What a tour picked from elsewhere (another trip, a collection) is measured by, as its list carries it. */
type MeasuredTour = Pick<
TourLibraryItem,
| 'tour_type'
| 'distance'
| 'elevation_gain'
| 'elevation_loss'
| 'duration'
| 'planned_duration_minutes'
| 'break_additional_minutes'
| 'station_count'
| 'station_stay_minutes'
| 'station_stays_unknown'
>;
/** 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);
}
/** 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 }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<>
{measureBadges(tour, unit, t).map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`${testPrefix}-${badge.key}`}
/>
))}
</>
);
}
/**
* The facts of a chosen tour as its preview shows them, each only where the
* tour has it: where it comes from first, then its type, distance, planned
* time, heights and how many control points it brings.
*/
export function tourPreviewFacts(
tour: MeasuredTour,
waypoints: number,
unit: Unit,
t: Translate,
from: TourMetricField
): TourMetricField[] {
const total = plannedTotalText(tourPlannedTimes(tour), t);
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
from,
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** Where a preview's answer is: on its way, there, or not to be had. */
type PreviewStatus = 'loading' | 'ready' | 'error' | 'offline' | null;
/**
* A tour's line on a small map, the way a preview shows it before anything is
* taken over: drawn once its points are in, a spinner while they load, a short
* line of text when they cannot be read.
*/
export function TourLinePreview({
line,
focusKey,
status,
failedText,
}: {
line: ReadonlyArray<readonly unknown[]> | undefined;
focusKey: string;
status: PreviewStatus;
/** What a preview that could not be read says; offline says that it is offline. */
failedText: string;
}) {
const { t } = useTranslation();
const tileUrl = useTileUrl(OFM_POSITRON);
const route = useMemo(
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
() =>
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
[line]
);
return (
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
{route.length > 0 && (
<MapViewAuto
places={[]}
route={route}
tileUrl={tileUrl}
focusPoints={route[0]}
focusKey={focusKey}
followSelection={false}
/>
)}
{status === 'loading' && (
<span className="absolute inset-0 grid place-items-center text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
</span>
)}
{(status === 'error' || status === 'offline') && (
<span
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
style={fs(12, 'body')}
>
{status === 'offline' ? t('tours.library.offline') : failedText}
</span>
)}
</div>
);
}
@@ -50,6 +50,7 @@ import { TourAddMenu } from './TourAddMenu'
import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignment'
import WandererMark from '../wanderer/WandererMark'
import { TourGpxExportBarButton } from '../TourGpxExportButton'
import { TourSaveToCollectionBarButton } from '../collection/TourSaveToCollectionButton'
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
import { WalkImportDraftNotice } from '../walk/WalkImportDialog'
import { WalkRoutePendingNotice } from '../walk/WalkRoutePending'
@@ -328,7 +329,10 @@ function GpxViewRail({
aside={<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>}
/>
{tour.place_id > 0 && (
<TourGpxExportBarButton target={{ tripId: planner.tripId, placeId: tour.place_id, name: tour.name }} unsaved={false} />
<>
<TourGpxExportBarButton target={{ tripId: planner.tripId, placeId: tour.place_id, name: tour.name }} unsaved={false} />
<TourSaveToCollectionBarButton target={{ tripId: planner.tripId, placeId: tour.place_id, name: tour.name }} unsaved={false} />
</>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
@@ -449,10 +453,16 @@ export function TourPlannerRail({ planner,
</BarButton>
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
{planner.mode.type === 'edit-saved' && planner.mode.placeId > 0 && (
<TourGpxExportBarButton
target={{ tripId: planner.tripId, placeId: planner.mode.placeId, name: planner.name }}
unsaved={planner.hasUnsavedChanges}
/>
<>
<TourGpxExportBarButton
target={{ tripId: planner.tripId, placeId: planner.mode.placeId, name: planner.name }}
unsaved={planner.hasUnsavedChanges}
/>
<TourSaveToCollectionBarButton
target={{ tripId: planner.tripId, placeId: planner.mode.placeId, name: planner.name }}
unsaved={planner.hasUnsavedChanges}
/>
</>
)}
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={locked || !planner.canUndo}>
<Undo2 size={15} strokeWidth={2} />