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
+54
View File
@@ -0,0 +1,54 @@
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
//
// LIB-03: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { collectionToursApi } from './collectionTours';
const BASE = '/api/addons/collections/3/tours';
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType | null) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
};
}
describe('collectionToursApi', () => {
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
server.use(
http.get(BASE, record({ tours: [] })),
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
http.post(BASE, record({ tour: { id: 5 } })),
http.delete(`${BASE}/4`, record({ success: true }))
);
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
tour: { id: 5 },
});
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{ method: 'GET', path: `${BASE}/4`, body: null },
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
{ method: 'DELETE', path: `${BASE}/4`, body: null },
]);
});
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
TourCreateResponse,
} from '@trek/shared';
import apiClient from './client';
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
export const collectionToursApi = {
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
apiClient.post(base(collectionId), body).then((r) => r.data),
remove: (collectionId: number, tourId: number): Promise<void> =>
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
};
@@ -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} />
+5
View File
@@ -18,6 +18,7 @@ import ImportCollectionModal from '../components/Collections/ImportCollectionMod
import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail'
import LabelManager from '../components/Collections/LabelManager'
import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal'
import { CollectionToursSection } from '../components/Tours/collection/CollectionToursSection'
import { useCollections } from './collections/useCollections'
import EmptyState from '../components/shared/EmptyState'
import '../styles/dashboard.css'
@@ -315,6 +316,10 @@ function CollectionsPageDesktop(): React.ReactElement {
</div>
)}
{/* The whole tours the list keeps (LIB-03), above its places. */}
{isRealList && typeof c.activeId === 'number' && (
<CollectionToursSection collectionId={c.activeId} canRemove={c.canDelete} />
)}
{body}
</>
)}
@@ -9,6 +9,7 @@ import { useToast } from '../../components/shared/Toast'
import { collectionsApi } from '../../api/collections'
import { downloadCollectionFile, downloadCollectionGpx, type CollectionExportFormat } from '../../components/Collections/collectionFile'
import { getApiErrorMessage } from '../../types'
import { canAddToCollection } from '../../utils/collectionAccess'
import { addListener, removeListener } from '../../api/websocket'
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
import type { ActiveCollectionId } from '../../store/collectionStore'
@@ -222,9 +223,7 @@ export function useCollections() {
* so the import dialog never offers a list the import would refuse.
*/
const writableLists = useMemo(
() => collections.filter(c => c.is_owner !== false || (c.members ?? []).some(
m => m.user_id === currentUserId && m.status === 'accepted' && (m.role === 'editor' || m.role === 'admin'),
)),
() => collections.filter(c => canAddToCollection(c, currentUserId)),
[collections, currentUserId],
)
const sharedLists = useMemo(() => collections.filter(c => c.is_owner === false), [collections])
@@ -0,0 +1,61 @@
import type { Collection, TourCreateResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { collectionsApi } from '../api/collections';
import { collectionToursApi } from '../api/collectionTours';
import { collectionToursRepo } from './collectionToursRepo';
import { OnlineOnlyError } from './onlineOnly';
// FE-REPO-COLLECTION-TOURS-001 to FE-REPO-COLLECTION-TOURS-003
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
const cache = vi.hoisted(() => ({ cacheTourAnswer: vi.fn() }));
vi.mock('../db/cacheTour', () => cache);
beforeEach(() => {
network.offline = false;
cache.cacheTourAnswer.mockReset();
vi.restoreAllMocks();
});
describe('collectionToursRepo', () => {
it('FE-REPO-COLLECTION-TOURS-001: reads and writes only online, never from a cache', async () => {
network.offline = true;
const list = vi.spyOn(collectionToursApi, 'list');
await expect(collectionToursRepo.lists()).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(collectionToursRepo.list(3)).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(collectionToursRepo.detail(3, 4)).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(collectionToursRepo.save(3, { source_trip_id: 2, source_place_id: 7 })).rejects.toBeInstanceOf(
OnlineOnlyError
);
await expect(collectionToursRepo.remove(3, 4)).rejects.toBeInstanceOf(OnlineOnlyError);
await expect(
collectionToursRepo.takeOver(6, { collection_id: 3, collection_tour_id: 4, include_waypoints: true })
).rejects.toBeInstanceOf(OnlineOnlyError);
expect(list).not.toHaveBeenCalled();
});
it('FE-REPO-COLLECTION-TOURS-002: passes every call on and answers the user’s lists', async () => {
const lists = [{ id: 3, owner_id: 1, name: 'Hikes' }] as Collection[];
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: lists, incomingInvites: [] });
vi.spyOn(collectionToursApi, 'list').mockResolvedValue({ tours: [] });
vi.spyOn(collectionToursApi, 'detail').mockResolvedValue({ tour: {} as never, line: [], points: [], trips: [] });
const save = vi.spyOn(collectionToursApi, 'save').mockResolvedValue({ tour: { id: 5 } as never });
const remove = vi.spyOn(collectionToursApi, 'remove').mockResolvedValue(undefined);
expect(await collectionToursRepo.lists()).toBe(lists);
expect(await collectionToursRepo.list(3)).toEqual({ tours: [] });
expect((await collectionToursRepo.detail(3, 4)).points).toEqual([]);
await collectionToursRepo.save(3, { source_trip_id: 2, source_place_id: 7 });
await collectionToursRepo.remove(3, 4);
expect(save).toHaveBeenCalledWith(3, { source_trip_id: 2, source_place_id: 7 });
expect(remove).toHaveBeenCalledWith(3, 4);
});
it('FE-REPO-COLLECTION-TOURS-003: a tour taken into a trip goes into that trip’s cache', async () => {
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourCreateResponse;
vi.spyOn(collectionToursApi, 'takeOver').mockResolvedValue(answer);
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect(await collectionToursRepo.takeOver(6, body)).toBe(answer);
expect(cache.cacheTourAnswer).toHaveBeenCalledWith(6, answer);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type {
Collection,
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
TourCreateResponse,
} from '@trek/shared';
import { collectionsApi } from '../api/collections';
import { collectionToursApi } from '../api/collectionTours';
import { cacheTourAnswer } from '../db/cacheTour';
import { requireOnline } from './onlineOnly';
/**
* LIB-03, online only on purpose, like the collections themselves: a kept
* tour lives apart from every trip, so there is no trip cache it belongs to,
* and who may read, keep or take one is decided by the server at the moment
* it is asked. A tour taken into a trip goes into that trip's cache once it
* exists, as the take-over from another trip does.
*/
export const collectionToursRepo = {
/** The user's collections, to choose the one a tour is kept in. */
async lists(): Promise<Collection[]> {
requireOnline();
return (await collectionsApi.list()).collections;
},
async list(collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> {
requireOnline();
return await collectionToursApi.list(collectionId, signal);
},
async detail(collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> {
requireOnline();
return await collectionToursApi.detail(collectionId, tourId, signal);
},
async save(collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> {
requireOnline();
return await collectionToursApi.save(collectionId, body);
},
async remove(collectionId: number, tourId: number): Promise<void> {
requireOnline();
await collectionToursApi.remove(collectionId, tourId);
},
async takeOver(tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> {
requireOnline();
const result = await collectionToursApi.takeOver(tripId, body);
await cacheTourAnswer(tripId, result);
return result;
},
};
@@ -0,0 +1,86 @@
// LIB-03: the tours the open collection keeps, read when it opens, kept current
// by its members' changes and never shown under another collection.
import type { CollectionTourItem } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { addListener, removeListener } from '../api/websocket';
import { collectionToursRepo } from '../repo/collectionToursRepo';
import { OnlineOnlyError } from '../repo/onlineOnly';
import type { WebSocketEvent } from '../types';
import { useCollectionTourStore, watchCollectionTours } from './collectionTourStore';
// FE-STORE-COLLECTION-TOURS-001 to FE-STORE-COLLECTION-TOURS-005
const tour = (id: number, name: string) => ({ id, collection_id: 3, name }) as CollectionTourItem;
function later<T>() {
let settle: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, settle };
}
beforeEach(() => {
useCollectionTourStore.getState().clear();
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
});
afterEach(() => vi.restoreAllMocks());
describe('collectionTourStore', () => {
it('FE-STORE-COLLECTION-TOURS-001: reads a collection’s tours and says how far it got', async () => {
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [tour(1, 'Ridge')] });
const loading = useCollectionTourStore.getState().load(3);
expect(useCollectionTourStore.getState()).toMatchObject({ collectionId: 3, status: 'loading', tours: [] });
await loading;
expect(useCollectionTourStore.getState()).toMatchObject({ status: 'ready', tours: [tour(1, 'Ridge')] });
});
it('FE-STORE-COLLECTION-TOURS-002: an answer for a collection left meanwhile counts for nothing', async () => {
const slow = later<{ tours: CollectionTourItem[] }>();
vi.spyOn(collectionToursRepo, 'list')
.mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce({ tours: [tour(2, 'Lake')] });
const first = useCollectionTourStore.getState().load(3);
await useCollectionTourStore.getState().load(4);
slow.settle({ tours: [tour(1, 'Ridge')] });
await first;
expect(useCollectionTourStore.getState()).toMatchObject({ collectionId: 4, tours: [tour(2, 'Lake')] });
});
it('FE-STORE-COLLECTION-TOURS-003: offline and failed reads say so', async () => {
vi.spyOn(collectionToursRepo, 'list')
.mockRejectedValueOnce(new OnlineOnlyError())
.mockRejectedValueOnce(new Error('500'));
await useCollectionTourStore.getState().load(3);
expect(useCollectionTourStore.getState().status).toBe('offline');
await useCollectionTourStore.getState().load(3);
expect(useCollectionTourStore.getState().status).toBe('error');
});
it('FE-STORE-COLLECTION-TOURS-004: removing takes the tour off at once and puts it back on a refusal', async () => {
vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [tour(1, 'Ridge'), tour(2, 'Lake')] });
await useCollectionTourStore.getState().load(3);
const remove = vi.spyOn(collectionToursRepo, 'remove').mockResolvedValueOnce(undefined);
await useCollectionTourStore.getState().remove(3, 1);
expect(useCollectionTourStore.getState().tours.map((kept) => kept.id)).toEqual([2]);
remove.mockRejectedValueOnce(new Error('403'));
await expect(useCollectionTourStore.getState().remove(3, 2)).rejects.toThrow('403');
expect(useCollectionTourStore.getState().tours.map((kept) => kept.id)).toEqual([2]);
});
it('FE-STORE-COLLECTION-TOURS-005: reads again when a member changes the open collection, and stops listening', async () => {
const list = vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [] });
await useCollectionTourStore.getState().load(3);
list.mockClear();
const stop = watchCollectionTours();
const onEvent = vi.mocked(addListener).mock.calls[0]?.[0] as (event: WebSocketEvent) => void;
onEvent({ type: 'collections:updated', collectionId: 4 });
onEvent({ type: 'collections:left', collectionId: 3 });
expect(list).not.toHaveBeenCalled();
onEvent({ type: 'collections:updated', collectionId: 3 });
expect(list).toHaveBeenCalledWith(3);
stop();
expect(removeListener).toHaveBeenCalledWith(onEvent);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { CollectionTourItem } from '@trek/shared';
import { create } from 'zustand';
import { addListener, removeListener } from '../api/websocket';
import { collectionToursRepo } from '../repo/collectionToursRepo';
import { OnlineOnlyError } from '../repo/onlineOnly';
import type { WebSocketEvent } from '../types';
/** How far the open collection's tours are: asked for, there, or not to be had right now. */
type CollectionTourStatus = 'idle' | 'loading' | 'ready' | 'error' | 'offline';
interface CollectionTourState {
/** The collection the tours are read for. */
collectionId: number | null;
tours: CollectionTourItem[];
status: CollectionTourStatus;
load: (collectionId: number) => Promise<void>;
/** Removes one kept tour; a refusal puts it back and throws. */
remove: (collectionId: number, tourId: number) => Promise<void>;
clear: () => void;
}
/**
* LIB-03: the whole tours kept in the collection that is open. Read when it
* opens, again when another member keeps or removes one (watchCollectionTours),
* and never shown under another collection, not even while that one loads.
*/
export const useCollectionTourStore = create<CollectionTourState>((set, get) => ({
collectionId: null,
tours: [],
status: 'idle',
load: async (collectionId) => {
// Another collection's tours never show under this one.
if (get().collectionId !== collectionId) set({ collectionId, tours: [], status: 'loading' });
try {
const { tours } = await collectionToursRepo.list(collectionId);
if (get().collectionId === collectionId) set({ tours, status: 'ready' });
} catch (err: unknown) {
if (get().collectionId === collectionId) set({ status: err instanceof OnlineOnlyError ? 'offline' : 'error' });
}
},
remove: async (collectionId, tourId) => {
const before = get().tours;
if (get().collectionId === collectionId) set({ tours: before.filter((tour) => tour.id !== tourId) });
try {
await collectionToursRepo.remove(collectionId, tourId);
} catch (err) {
if (get().collectionId === collectionId) set({ tours: before });
throw err;
}
},
clear: () => set({ collectionId: null, tours: [], status: 'idle' }),
}));
/**
* Keeps the open collection's tours current while its members work on it: a
* `collections:updated` for that collection reads them again. Returns the
* function that stops listening.
*/
export function watchCollectionTours(): () => void {
const onEvent = (event: WebSocketEvent) => {
const { collectionId } = useCollectionTourStore.getState();
if (event?.type !== 'collections:updated' || collectionId === null || event.collectionId !== collectionId) return;
void useCollectionTourStore.getState().load(collectionId);
};
addListener(onEvent);
return () => removeListener(onEvent);
}
+29
View File
@@ -0,0 +1,29 @@
import type { CollectionMember } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { canAddToCollection } from './collectionAccess';
// FE-COLLECTION-ACCESS-001 to FE-COLLECTION-ACCESS-002
const member = (over: Partial<CollectionMember>): CollectionMember => ({
user_id: 7,
username: 'mia',
status: 'accepted',
role: 'editor',
...over,
});
describe('canAddToCollection', () => {
it('FE-COLLECTION-ACCESS-001: a list of one’s own always takes more', () => {
expect(canAddToCollection({ is_owner: true }, 7)).toBe(true);
expect(canAddToCollection({}, undefined)).toBe(true);
});
it('FE-COLLECTION-ACCESS-002: a shared list only for an accepted editor or admin', () => {
expect(canAddToCollection({ is_owner: false, members: [member({})] }, 7)).toBe(true);
expect(canAddToCollection({ is_owner: false, members: [member({ role: 'admin' })] }, 7)).toBe(true);
expect(canAddToCollection({ is_owner: false, members: [member({ role: 'viewer' })] }, 7)).toBe(false);
expect(canAddToCollection({ is_owner: false, members: [member({ status: 'pending' })] }, 7)).toBe(false);
expect(canAddToCollection({ is_owner: false, members: [member({ user_id: 8 })] }, 7)).toBe(false);
expect(canAddToCollection({ is_owner: false }, 7)).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { Collection } from '@trek/shared';
/**
* Whether the user may add to a list: their own, or a shared one where they
* are an accepted editor or admin. The rule the server applies on the way in,
* so a dialog never offers a list the write would refuse.
*/
export function canAddToCollection(
collection: Pick<Collection, 'is_owner' | 'members'>,
userId: number | null | undefined
): boolean {
if (collection.is_owner !== false) return true;
return (collection.members ?? []).some(
(member) =>
member.user_id === userId &&
member.status === 'accepted' &&
(member.role === 'editor' || member.role === 'admin')
);
}
+1
View File
@@ -51,6 +51,7 @@ export const UNRESET_STORES: Record<string, string> = {
useJourneyStore: 'reset in the journey tests themselves; importing it here pulls in a circular import',
useBackgroundTasksStore: NOT_YET,
useCollectionStore: NOT_YET,
useCollectionTourStore: 'its own tests put it back with clear(); it holds one open collection and nothing else reads it',
useDocSyncOfferStore: NOT_YET,
useHelpStore: NOT_YET,
usePluginStore: NOT_YET,
+1
View File
@@ -20,6 +20,7 @@
"CollectionMembers": "collections",
"CollectionPlaceRatings": "collections",
"CollectionPlaces": "collections",
"CollectionTours": "tours",
"Collections": "collections",
"DawarichConnections": "integrations",
"DawarichVisitSuggestions": "integrations",
@@ -0,0 +1,101 @@
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { CollectionToursRepository } from '../repositories/CollectionTours.repository';
import { DbTimestampType } from '../types';
import { Collections } from './Collections.entity';
import { TourTypes } from './TourTypes.entity';
import { Users } from './Users.entity';
export class CollectionTours {
[EntityRepositoryType]?: CollectionToursRepository;
id!: number & Opt;
collection!: Ref<Collections>;
collection_id!: number;
saved_by?: number | null;
name!: string;
description?: string | null;
website?: string | null;
route_geometry?: string | null;
waypoints!: string;
tour_type!: string;
distance?: number | null;
elevation_gain?: number | null;
elevation_loss?: number | null;
duration?: number | null;
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
difficulty?: string | null;
wanderer_ref?: string | null;
match_confidence?: number | null;
max_hiking_difficulty?: number | null;
bike_profile?: string | null;
winter_activity?: string | null;
run_profile?: string | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: string | null;
ski_technique?: string | null;
boat_type?: string | null;
water_context?: string | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: string | null;
track_marks?: string | null;
hiking_preferences?: string | null;
origin!: string;
origin_file_name?: string | null;
track_started_at?: string | null;
track_ended_at?: string | null;
source_trip_title!: string;
source_tour_name!: string;
created_at!: string & Opt;
savedByRef?: Ref<Users> | null;
tourTypeRef!: Ref<TourTypes>;
}
export const CollectionToursSchema = defineEntity({
class: CollectionTours,
repository: () => CollectionToursRepository,
properties: {
id: p.integer().primary(),
collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_tours_collection'),
collection_id: p.integer().persist(false).index('idx_collection_tours_collection'),
saved_by: p.integer().nullable().persist(false).index('idx_collection_tours_saved_by'),
name: p.text(),
description: p.text().nullable(),
website: p.text().nullable(),
route_geometry: p.text().nullable(),
waypoints: p.text(),
tour_type: p.text().persist(false).index('idx_collection_tours_tour_type'),
distance: p.double().nullable(),
elevation_gain: p.double().nullable(),
elevation_loss: p.double().nullable(),
duration: p.double().nullable(),
planned_duration_minutes: p.integer().nullable(),
break_additional_minutes: p.integer().nullable(),
difficulty: p.text().nullable(),
wanderer_ref: p.text().nullable(),
match_confidence: p.double().nullable(),
max_hiking_difficulty: p.integer().nullable(),
bike_profile: p.text().nullable(),
winter_activity: p.text().nullable(),
run_profile: p.text().nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
ski_discipline: p.text().nullable(),
ski_technique: p.text().nullable(),
boat_type: p.text().nullable(),
water_context: p.text().nullable(),
paddle_speed_kmh: p.double().nullable(),
portage_speed_kmh: p.double().nullable(),
paddle_lake_style: p.text().nullable(),
track_marks: p.text().nullable(),
hiking_preferences: p.text().nullable(),
origin: p.text(),
origin_file_name: p.text().nullable(),
track_started_at: p.type(DbTimestampType).nullable(),
track_ended_at: p.type(DbTimestampType).nullable(),
source_trip_title: p.text(),
source_tour_name: p.text(),
created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
savedByRef: () => p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden().index('idx_collection_tours_saved_by'),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_collection_tours_tour_type'),
},
});
@@ -4,6 +4,7 @@ import { DbTimestampType } from '../types';
import { CollectionLabels } from './CollectionLabels.entity';
import { CollectionMembers } from './CollectionMembers.entity';
import { CollectionPlaces } from './CollectionPlaces.entity';
import { CollectionTours } from './CollectionTours.entity';
import { Users } from './Users.entity';
export class Collections {
@@ -23,6 +24,7 @@ export class Collections {
collection_labels_collection = new Collection<CollectionLabels>(this);
collection_members_collection = new Collection<CollectionMembers>(this);
collection_places_collection = new Collection<CollectionPlaces>(this);
collection_tours_collection = new Collection<CollectionTours>(this);
}
export const CollectionsSchema = defineEntity({
@@ -44,5 +46,6 @@ export const CollectionsSchema = defineEntity({
collection_labels_collection: () => p.oneToMany(CollectionLabels).mappedBy('collection').hidden(),
collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('collection').hidden(),
collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('collection').hidden(),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('collection').hidden(),
},
});
@@ -1,5 +1,6 @@
import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { TourTypesRepository } from '../repositories/TourTypes.repository';
import { CollectionTours } from './CollectionTours.entity';
import { Tours } from './Tours.entity';
export class TourTypes {
@@ -13,6 +14,7 @@ export class TourTypes {
is_sport: number & Opt = 1;
enabled: number & Opt = 1;
sort_order: number & Opt = 0;
collection_tours_collection = new Collection<CollectionTours>(this);
tours_collection = new Collection<Tours>(this);
}
@@ -28,6 +30,7 @@ export const TourTypesSchema = defineEntity({
is_sport: p.integer().default(1),
enabled: p.integer().default(1),
sort_order: p.integer().default(0),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('tourTypeRef').hidden(),
tours_collection: () => p.oneToMany(Tours).mappedBy('tourTypeRef').hidden(),
},
});
+3
View File
@@ -18,6 +18,7 @@ import { CollabPolls } from './CollabPolls.entity';
import { CollectionMembers } from './CollectionMembers.entity';
import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity';
import { CollectionPlaces } from './CollectionPlaces.entity';
import { CollectionTours } from './CollectionTours.entity';
import { Collections } from './Collections.entity';
import { DawarichConnections } from './DawarichConnections.entity';
import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity';
@@ -142,6 +143,7 @@ export class Users {
collection_place_ratings_collection = new Collection<CollectionPlaceRatings>(this);
collection_places_collection = new Collection<CollectionPlaces>(this);
collection_places_collection1 = new Collection<CollectionPlaces>(this);
collection_tours_collection = new Collection<CollectionTours>(this);
collections_collection = new Collection<Collections>(this);
dawarich_connections: Ref<DawarichConnections> | null = null;
dawarich_visit_suggestions_collection = new Collection<DawarichVisitSuggestions>(this);
@@ -289,6 +291,7 @@ export const UsersSchema = defineEntity({
collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('user').hidden(),
collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('owner').hidden(),
collection_places_collection1: () => p.oneToMany(CollectionPlaces).mappedBy('savedByRef').hidden(),
collection_tours_collection: () => p.oneToMany(CollectionTours).mappedBy('savedByRef').hidden(),
collections_collection: () => p.oneToMany(Collections).mappedBy('owner').hidden(),
dawarich_connections: () => p.oneToOne(DawarichConnections).ref().mappedBy('user').hidden(),
dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('user').hidden(),
+2
View File
@@ -20,6 +20,7 @@ import { CollectionLabelsSchema } from './CollectionLabels.entity';
import { CollectionMembersSchema } from './CollectionMembers.entity';
import { CollectionPlaceRatingsSchema } from './CollectionPlaceRatings.entity';
import { CollectionPlacesSchema } from './CollectionPlaces.entity';
import { CollectionToursSchema } from './CollectionTours.entity';
import { CollectionsSchema } from './Collections.entity';
import { DawarichConnectionsSchema } from './DawarichConnections.entity';
import { DawarichVisitSuggestionsSchema } from './DawarichVisitSuggestions.entity';
@@ -168,6 +169,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
CollectionMembersSchema,
CollectionPlaceRatingsSchema,
CollectionPlacesSchema,
CollectionToursSchema,
CollectionsSchema,
DawarichConnectionsSchema,
DawarichVisitSuggestionsSchema,
+11
View File
@@ -267,3 +267,14 @@ export const TOUR_CHANGE_STEP_BEFORE: JsonColumn<TourChangeStepBefore | null> =
.nullable(),
fallback: () => null,
};
/**
* LIB-03: the control points of a tour kept in a collection, in route order,
* as they were when it was saved. A broken row reads as none and is logged;
* the line is kept in a column of its own and stays as it was.
*/
export const COLLECTION_TOUR_WAYPOINTS: JsonColumn<TourWaypoint[]> = {
column: 'collection_tours.waypoints',
schema: z.array(tourWaypointSchema),
fallback: () => [],
};
@@ -0,0 +1,45 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface CollectionToursTable {
id: InsertOptional<number>;
collection_id: number;
saved_by: InsertOptional<number | null>;
name: string;
description: InsertOptional<string | null>;
website: InsertOptional<string | null>;
route_geometry: InsertOptional<string | null>;
waypoints: string;
tour_type: string;
distance: InsertOptional<number | null>;
elevation_gain: InsertOptional<number | null>;
elevation_loss: InsertOptional<number | null>;
duration: InsertOptional<number | null>;
planned_duration_minutes: InsertOptional<number | null>;
break_additional_minutes: InsertOptional<number | null>;
difficulty: InsertOptional<string | null>;
wanderer_ref: InsertOptional<string | null>;
match_confidence: InsertOptional<number | null>;
max_hiking_difficulty: InsertOptional<number | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
ski_technique: InsertOptional<string | null>;
boat_type: InsertOptional<string | null>;
water_context: InsertOptional<string | null>;
paddle_speed_kmh: InsertOptional<number | null>;
portage_speed_kmh: InsertOptional<number | null>;
paddle_lake_style: InsertOptional<string | null>;
track_marks: InsertOptional<string | null>;
hiking_preferences: InsertOptional<string | null>;
origin: string;
origin_file_name: InsertOptional<string | null>;
track_started_at: InsertOptional<string | null>;
track_ended_at: InsertOptional<string | null>;
source_trip_title: string;
source_tour_name: string;
created_at: InsertOptional<string>;
}
+2
View File
@@ -23,6 +23,7 @@ import type { CollectionPlaceLabelsTable } from './CollectionPlaceLabels.table';
import type { CollectionPlaceRatingsTable } from './CollectionPlaceRatings.table';
import type { CollectionPlaceTagsTable } from './CollectionPlaceTags.table';
import type { CollectionPlacesTable } from './CollectionPlaces.table';
import type { CollectionToursTable } from './CollectionTours.table';
import type { CollectionsTable } from './Collections.table';
import type { DawarichConnectionsTable } from './DawarichConnections.table';
import type { DawarichVisitSuggestionsTable } from './DawarichVisitSuggestions.table';
@@ -177,6 +178,7 @@ export interface DB {
collection_place_ratings: CollectionPlaceRatingsTable;
collection_place_tags: CollectionPlaceTagsTable;
collection_places: CollectionPlacesTable;
collection_tours: CollectionToursTable;
collections: CollectionsTable;
dawarich_connections: DawarichConnectionsTable;
dawarich_visit_suggestions: DawarichVisitSuggestionsTable;
@@ -0,0 +1,64 @@
import { Migration } from '@mikro-orm/migrations';
/**
* LIB-03: a collection keeps whole tours beside its places. Each row is an
* independent snapshot of a tour as it was saved: the line, the type, every
* measure and setting of the `tours` facet, and its control points as one
* JSON array in route order. Nothing points back at the tour it came from;
* the trip's title and the tour's name are kept as text, as written then,
* because the trip may be deleted, renamed or out of a member's reach later.
* `saved_by` names who kept it and turns unknown when that account goes. The
* rows go with their collection.
*/
export class Migration20200101046100_a_collection_keeps_whole_tours extends Migration {
override name = 'Migration20200101046100_a_collection_keeps_whole_tours';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_tours (
id INTEGER PRIMARY KEY,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
website TEXT,
route_geometry TEXT,
waypoints TEXT NOT NULL,
tour_type TEXT NOT NULL REFERENCES tour_types(key),
distance REAL,
elevation_gain REAL,
elevation_loss REAL,
duration REAL,
planned_duration_minutes INTEGER,
break_additional_minutes INTEGER,
difficulty TEXT,
wanderer_ref TEXT,
match_confidence REAL,
max_hiking_difficulty INTEGER,
bike_profile TEXT,
winter_activity TEXT,
run_profile TEXT,
run_pace_seconds_per_km INTEGER,
ski_discipline TEXT,
ski_technique TEXT,
boat_type TEXT,
water_context TEXT,
paddle_speed_kmh REAL,
portage_speed_kmh REAL,
paddle_lake_style TEXT,
track_marks TEXT,
hiking_preferences TEXT,
origin TEXT NOT NULL,
origin_file_name TEXT,
track_started_at DATETIME,
track_ended_at DATETIME,
source_trip_title TEXT NOT NULL,
source_tour_name TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_collection ON collection_tours(collection_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_saved_by ON collection_tours(saved_by)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_collection_tours_tour_type ON collection_tours(tour_type)');
}
}
@@ -0,0 +1,148 @@
import { decodeJson } from '../../utils/json-column';
import type { CollectionTours } from '../entities/CollectionTours.entity';
import { COLLECTION_TOUR_WAYPOINTS } from '../json-columns';
import type { DB } from '../kysely/db';
import type { TourWaypointRow } from './TourWaypoints.repository';
import { TrekRepository } from './_shared/trek-repository';
/**
* LIB-03: a tour kept in a collection, every column as stored but its control
* points, which come decoded in route order, and the name of who saved it
* (null once that account is gone).
*/
export interface CollectionTourRow {
id: number;
collection_id: number;
saved_by: number | null;
saved_by_name: string | null;
name: string;
description: string | null;
website: string | null;
route_geometry: string | null;
waypoints: TourWaypointRow[];
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
planned_duration_minutes: number | null;
break_additional_minutes: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
hiking_preferences: string | null;
origin: string;
origin_file_name: string | null;
track_started_at: string | null;
track_ended_at: string | null;
source_trip_title: string;
source_tour_name: string;
created_at: string;
}
/**
* What saving a tour into a collection writes: the whole snapshot, written
* once and never changed, its points already encoded for their column
* (`COLLECTION_TOUR_WAYPOINTS`) by the service that built them.
*/
export type CollectionTourInsert = Omit<CollectionTourRow, 'id' | 'saved_by_name' | 'created_at' | 'waypoints'> & {
waypoints: string;
};
/** A trip a kept tour could be taken into, with its owner for the permission check. */
interface CollectionTourTargetRow {
id: number;
user_id: number;
title: string;
start_date: string | null;
end_date: string | null;
}
type CollectionToursKyselyDB = Pick<DB, 'collection_tours' | 'users' | 'trips'>;
/**
* `collection_tours` (LIB-03): whole tours kept in a collection, apart from
* any trip. A row is written whole when a tour is saved and only ever read or
* deleted afterwards; it goes with its collection.
*/
export class CollectionToursRepository extends TrekRepository<CollectionTours> {
private db() {
return this.kysely<CollectionToursKyselyDB>();
}
/** The shared SELECT of {@link listForCollection} and {@link findInCollection}. */
private rowQuery() {
return this.db()
.selectFrom('collection_tours as ct')
.leftJoin('users as u', 'u.id', 'ct.saved_by')
.selectAll('ct')
.select('u.username as saved_by_name');
}
/** CT1: the tours kept in one collection, the newest first; the id breaks ties inside one second. */
async listForCollection(collection_id: number): Promise<CollectionTourRow[]> {
const rows = await this.rowQuery()
.where('ct.collection_id', '=', collection_id)
.orderBy('ct.created_at', 'desc')
.orderBy('ct.id', 'desc')
.execute();
return rows.map(readRow);
}
/** CT2: one kept tour, undefined unless it is in that collection. */
async findInCollection(collection_id: number, id: number): Promise<CollectionTourRow | undefined> {
const row = await this.rowQuery()
.where('ct.collection_id', '=', collection_id)
.where('ct.id', '=', id)
.executeTakeFirst();
return row ? readRow(row) : undefined;
}
/** CT3: keeps a snapshot in a collection and answers its id. */
async insertSnapshot(row: CollectionTourInsert): Promise<number> {
const inserted = await this.db()
.insertInto('collection_tours')
.values(row)
.returning('id')
.executeTakeFirstOrThrow();
return inserted.id;
}
/** CT4: removes a kept tour from its collection; false when it was not there. */
async deleteInCollection(collection_id: number, id: number): Promise<boolean> {
const result = await this.db()
.deleteFrom('collection_tours')
.where('collection_id', '=', collection_id)
.where('id', '=', id)
.executeTakeFirst();
return Number(result.numDeletedRows) > 0;
}
/** CT5: the given trips with their owner, title and dates, in no particular order; the caller orders them. */
async targetTrips(trip_ids: readonly number[]): Promise<CollectionTourTargetRow[]> {
if (trip_ids.length === 0) return [];
return await this.db()
.selectFrom('trips as t')
.select(['t.id', 't.user_id', 't.title', 't.start_date', 't.end_date'])
.where('t.id', 'in', [...trip_ids])
.execute();
}
}
/** A stored row as callers read it: its points decoded, a broken list read as none and logged. */
function readRow(row: Omit<CollectionTourRow, 'waypoints'> & { waypoints: string }): CollectionTourRow {
return { ...row, waypoints: decodeJson(COLLECTION_TOUR_WAYPOINTS, row.waypoints, `collection tour ${row.id}`) };
}
@@ -0,0 +1,108 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { DomainError } from '../common/domain-error';
import { ResponseContract } from '../common/response-contract';
import { toRowId } from '../common/row-id';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { CollectionToursService } from './collection-tours.service';
import { CollectionTourSaveDto, CollectionTourTakeOverDto } from './dto/collection-tours.dto';
import { Body, Controller, Delete, Get, Headers, Param, Post, UseGuards } from '@nestjs/common';
import {
collectionTourDetailResponseSchema,
collectionTourListResponseSchema,
collectionTourSaveResponseSchema,
successResponseSchema,
tourCreateResponseSchema,
} from '@trek/shared';
/** A route id as a row id; a malformed one is a missing row, never a 500. */
function rowId(raw: string, missing: string): number {
const id = toRowId(raw);
if (id === null) throw new DomainError(404, missing);
return id;
}
/**
* LIB-03 /api/addons/collections/:collectionId/tours: the whole tours a
* collection keeps. Not trip-scoped; the service checks the user's role in
* the collection (404 for one they cannot see, 403 for a role that may not)
* and, when a tour is kept, that they can open the trip it comes from.
*/
@Controller('api/addons/collections/:collectionId/tours')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class CollectionToursController {
constructor(private readonly kept: CollectionToursService) {}
@Get()
@ResponseContract(collectionTourListResponseSchema)
async list(@CurrentUser() user: User, @Param('collectionId') collectionId: string) {
return await this.kept.list(user.id, rowId(collectionId, 'Collection not found'));
}
@Post()
@ResponseContract(collectionTourSaveResponseSchema)
async save(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Body() body: CollectionTourSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.kept.save(user.id, rowId(collectionId, 'Collection not found'), body, socketId);
}
@Get(':tourId')
@ResponseContract(collectionTourDetailResponseSchema)
async detail(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Param('tourId') tourId: string,
) {
return await this.kept.detail(user, rowId(collectionId, 'Collection not found'), rowId(tourId, 'Tour not found'));
}
@Delete(':tourId')
@ResponseContract(successResponseSchema)
async remove(
@CurrentUser() user: User,
@Param('collectionId') collectionId: string,
@Param('tourId') tourId: string,
@Headers('x-socket-id') socketId?: string,
) {
await this.kept.remove(
user.id,
rowId(collectionId, 'Collection not found'),
rowId(tourId, 'Tour not found'),
socketId,
);
return { success: true as const };
}
}
/**
* LIB-03 POST /api/trips/:tripId/tours/from-collection: a tour kept in a
* collection made into a new tour of this trip. The guard checks this trip and
* 'place_edit' on it; the service checks the collection.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class CollectionTourTakeOverController {
constructor(private readonly kept: CollectionToursService) {}
@RequirePermission('place_edit')
@Post('from-collection')
@ResponseContract(tourCreateResponseSchema)
async takeOver(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: CollectionTourTakeOverDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.kept.takeOver(tripId, user.id, body, socketId);
}
}
@@ -0,0 +1,105 @@
import { COLLECTION_TOUR_WAYPOINTS } from '../../db/json-columns';
import type { CollectionTourInsert, CollectionTourRow } from '../../db/repositories/CollectionTours.repository';
import { encodeJson } from '../../utils/json-column';
import { copiedColumns, type TourCopySource } from './tour-copy.helpers';
import { parseRouteGeometry, thinnedLine } from './tours.helpers';
import {
COLLECTION_TOUR_PREVIEW_POINTS,
walkStationFacts,
type CollectionTourItem,
type CollectionTourPoint,
type TourOrigin,
type TourTypeKey,
} from '@trek/shared';
/** LIB-03: a kept tour as the collection lists it, its measures as they were saved, null staying null. */
export function keptTourItem(row: CollectionTourRow): CollectionTourItem {
return {
id: row.id,
collection_id: row.collection_id,
name: row.name,
description: row.description,
tour_type: row.tour_type as TourTypeKey,
distance: row.distance,
elevation_gain: row.elevation_gain,
elevation_loss: row.elevation_loss,
duration: row.duration,
planned_duration_minutes: row.planned_duration_minutes,
break_additional_minutes: row.break_additional_minutes,
max_hiking_difficulty: row.max_hiking_difficulty,
wanderer_ref: row.wanderer_ref,
origin: row.origin as TourOrigin,
has_line: row.route_geometry !== null,
waypoint_count: row.waypoints.length,
...walkStationFacts(row.tour_type, row.waypoints),
source: { tripTitle: row.source_trip_title, tourName: row.source_tour_name },
saved_by: row.saved_by_name,
saved_at: row.created_at,
};
}
/** The kept line thinned for the preview, as plain points. */
export function keptTourLine(row: Pick<CollectionTourRow, 'route_geometry'>): Array<[number, number]> {
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
return thinnedLine(line, COLLECTION_TOUR_PREVIEW_POINTS);
}
/** The kept control points in route order, as the preview names them. */
export function keptTourPoints(row: Pick<CollectionTourRow, 'waypoints'>): CollectionTourPoint[] {
return [...row.waypoints]
.sort((a, b) => a.sequence - b.sequence)
.map((point) => ({ role: point.role, name: point.name ?? null, shaping: point.shaping === true }));
}
/**
* What saving a tour into a collection keeps of it: the whole tour as its
* copy model reads it, under the name chosen, and the trip and tour it came
* from as they are called now. A point standing for a place of the trip
* stands for nothing outside it, so that link stays behind. The points are
* checked against their column's shape as they are encoded; a point that
* does not fit refuses the whole snapshot before anything is written.
*/
export function keptTourSnapshot(
source: TourCopySource,
keep: { collectionId: number; userId: number; name: string; tripTitle: string },
): CollectionTourInsert {
const { facet } = source;
return {
collection_id: keep.collectionId,
saved_by: keep.userId,
name: keep.name,
description: source.description,
website: source.website,
route_geometry: source.route_geometry,
waypoints: encodeJson(
COLLECTION_TOUR_WAYPOINTS,
source.waypoints.map((point) => ({ ...point, linked_place_id: null })),
),
...copiedColumns(facet),
origin_file_name: facet.origin_file_name,
source_trip_title: keep.tripTitle,
source_tour_name: source.name,
};
}
/**
* A kept tour as a new tour of a trip is made from it, through the one copy
* model. With its points left out it keeps its line as saved and takes no
* points at all: none are made up from the line.
*/
export function keptTourCopySource(row: CollectionTourRow, includeWaypoints: boolean): TourCopySource {
return {
place_id: null,
name: row.name,
description: row.description,
website: row.website,
route_geometry: row.route_geometry,
facet: {
...copiedColumns(row),
origin_file_name: row.origin_file_name,
taken_over_from_trip_title: row.source_trip_title,
taken_over_from_tour_name: row.source_tour_name,
},
waypoints: includeWaypoints ? row.waypoints : [],
};
}
@@ -0,0 +1,148 @@
import { ADDON_IDS } from '../../addons';
import { canRead, canReadTrips, canWrite } from '../../mcp/scopes';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
errorResult,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate, type HasAddons } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { CollectionToursService } from './collection-tours.service';
import { NotFoundException } from '@nestjs/common';
import {
collectionTourSaveRequestSchema,
collectionTourTakeOverRequestSchema,
idSchema,
type CollectionTourSaveRequest,
type CollectionTourTakeOverRequest,
} from '@trek/shared';
const save = collectionTourSaveRequestSchema.shape;
const takeOver = collectionTourTakeOverRequestSchema.shape;
const toursOn = addonGate(ADDON_IDS.TOURS);
const collectionsOn = addonGate(ADDON_IDS.COLLECTIONS);
/** A kept tour is a tour that lives in a collection, so both addons have to be on. */
async function keptToursOn(ctx: McpContext, self: HasAddons): Promise<boolean> {
return (await toursOn(ctx, self)) && (await collectionsOn(ctx, self));
}
/**
* LIB-03: the whole tours a collection keeps, over the same service and the
* same checks as REST. Reading and removing follow the collection's roles;
* keeping one also needs the trip it comes from to be open to the user, and
* taking one into a trip needs 'place_edit' on that trip.
*/
@McpController()
export class CollectionToursMcp {
constructor(
private readonly kept: CollectionToursService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
@Tool({
name: 'list_collection_tours',
description:
'List the whole tours kept in a collection (a saved-places list), the newest first: name, type, distance in km, ascent and descent in metres, the planned times, how many control points it keeps, whether it has a line, the trip and tour it was saved from and who saved it. Each is an independent snapshot; later changes to the tour it came from do not reach it. Values a tour does not have stay null.',
inputSchema: { collection_id: idSchema.describe('The collection to read') },
annotations: TOOL_ANNOTATIONS_READONLY,
when: keptToursOn,
access: { group: 'collections', mode: 'read' },
})
async listCollectionTours(input: { collection_id: number }, ctx: McpContext) {
return ok(await this.kept.list(ctx.userId, input.collection_id));
}
@Tool({
name: 'get_collection_tour',
description:
'One tour kept in a collection, with what taking it into a trip asks about: its facts, its line thinned to at most 400 points, its control points in order with their roles (start, via, end), names and whether they only shape the route, and every trip the user can open with can_add saying whether they may add a tour to it.',
inputSchema: {
collection_id: idSchema.describe('The collection the tour is kept in'),
collection_tour_id: idSchema.describe('The kept tour, as list_collection_tours gives it'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: keptToursOn,
access: (ctx) => canRead(ctx.scopes, 'collections') && canReadTrips(ctx.scopes),
})
async getCollectionTour(input: { collection_id: number; collection_tour_id: number }, ctx: McpContext) {
const role = (await this.guards.isAdminUser(ctx.userId)) ? 'admin' : 'user';
return ok(await this.kept.detail({ id: ctx.userId, role }, input.collection_id, input.collection_tour_id));
}
@Tool({
name: 'save_tour_to_collection',
description:
'Keep a saved tour of a trip in a collection the user may add to, as an independent snapshot: its line, type, measures, settings and control points in order with their roles, and the trip and tour it came from. Its days, its links to places of the trip and the obstacle decisions taken there stay in the trip. Later changes to the tour do not reach the kept one, and the trip is not changed. Give name to keep it under another name.',
inputSchema: {
collection_id: idSchema.describe('The collection to keep the tour in'),
source_trip_id: save.source_trip_id,
source_place_id: save.source_place_id,
name: save.name,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: keptToursOn,
access: (ctx) => canWrite(ctx.scopes, 'collections') && canReadTrips(ctx.scopes),
})
async saveTourToCollection(input: { collection_id: number } & CollectionTourSaveRequest, ctx: McpContext) {
const { collection_id, ...body } = input;
return ok(await this.kept.save(ctx.userId, collection_id, body));
}
@Tool({
name: 'take_over_collection_tour',
description:
'Make a new, independent tour of a trip from a tour kept in a collection. include_waypoints must be said: true brings its control points along in their order with their roles, false keeps only its line as saved and adds no points (a kept tour without a line needs its points). It gets no days and no new places, remembers the trip and tour it was first saved from, and neither the collection nor the tour it came from changes. Needs place_edit on the trip.',
inputSchema: {
tripId: idSchema.describe('The trip to add the tour to'),
collection_id: takeOver.collection_id,
collection_tour_id: takeOver.collection_tour_id,
include_waypoints: takeOver.include_waypoints,
name: takeOver.name,
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: keptToursOn,
access: (ctx) => canWrite(ctx.scopes, 'trips') && canRead(ctx.scopes, 'collections'),
})
async takeOverCollectionTour(input: { tripId: number } & CollectionTourTakeOverRequest, ctx: McpContext) {
const { tripId, ...body } = input;
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
try {
return ok(await this.kept.takeOver(String(tripId), ctx.userId, body));
} catch (err: unknown) {
// The copy model refuses a tour with nothing to copy the way LIB-02's take-over does.
if (err instanceof NotFoundException) return errorResult('Tour not found.');
throw err;
}
}
@Tool({
name: 'delete_collection_tour',
description:
'Remove a tour kept in a collection. Only the collection’s owner or an admin of it may. Tours already made from it in trips stay as they are.',
inputSchema: {
collection_id: idSchema.describe('The collection the tour is kept in'),
collection_tour_id: idSchema.describe('The kept tour to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: keptToursOn,
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionTour(input: { collection_id: number; collection_tour_id: number }, ctx: McpContext) {
await this.kept.remove(ctx.userId, input.collection_id, input.collection_tour_id);
return ok({ success: true });
}
}
@@ -0,0 +1,195 @@
import { ADDON_IDS } from '../../addons';
import { CollectionTours } from '../../db/entities/CollectionTours.entity';
import type { CollectionTourRow, CollectionToursRepository } from '../../db/repositories/CollectionTours.repository';
import { AddonsService } from '../addons/addons.service';
import { CollectionsService } from '../collections/collections.service';
import { DomainError } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import {
keptTourCopySource,
keptTourItem,
keptTourLine,
keptTourPoints,
keptTourSnapshot,
} from './collection-tours.helpers';
import { copyStart } from './tour-copy.helpers';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
CollectionTourTarget,
TourCreateResponse,
} from '@trek/shared';
/** Who asks, as the permission check reads them. */
interface CollectionTourUser {
id: number;
role: string;
}
const tourNotFound = () => new DomainError(404, 'Tour not found', { code: 'tour_not_found' });
/**
* LIB-03: whole tours kept in a collection and taken into a trip again. A
* kept tour is an independent snapshot made through the tours' one copy
* model; nothing keeps it in step with the tour it came from or with a tour
* later made from it. Who may see, keep and remove one follows the
* collection's own roles; who may take one into a trip follows that trip's
* `place_edit`, which the caller's guard checks. Both addons have to be on.
*/
@Injectable()
export class CollectionToursService {
constructor(
@InjectRepository(CollectionTours) private readonly kept: CollectionToursRepository,
private readonly collections: CollectionsService,
private readonly tours: ToursService,
private readonly access: TripAccessService,
private readonly membership: TripMembershipService,
private readonly permissions: PermissionsService,
private readonly addons: AddonsService,
private readonly uow: UnitOfWork,
) {}
/** The tours kept in a collection the user can see, the newest first. */
async list(userId: number, collectionId: number): Promise<CollectionTourListResponse> {
await this.assertCollectionsOn();
await this.collections.assertAccess(userId, collectionId);
return { tours: (await this.kept.listForCollection(collectionId)).map(keptTourItem) };
}
/** One kept tour with what taking it into a trip asks about: its line, its points and the trips it could go into. */
async detail(user: CollectionTourUser, collectionId: number, tourId: number): Promise<CollectionTourDetailResponse> {
const row = await this.readable(user.id, collectionId, tourId);
return {
tour: keptTourItem(row),
line: keptTourLine(row),
points: keptTourPoints(row),
trips: await this.targets(user),
};
}
/**
* Keeps a saved tour of a trip the user can open in a collection they may
* add to, as one independent snapshot. The tour is read inside the write, so
* a tour gone meanwhile leaves nothing behind; the trip is not changed.
*/
async save(
userId: number,
collectionId: number,
body: CollectionTourSaveRequest,
socketId?: string,
): Promise<CollectionTourSaveResponse> {
await this.assertCollectionsOn();
await this.collections.assertCanEdit(userId, collectionId);
if (!(await this.access.findAccessible(body.source_trip_id, userId))) throw tourNotFound();
const [trip] = await this.kept.targetTrips([body.source_trip_id]);
if (!trip) throw tourNotFound();
const id = await this.uow.transactional(async () => {
const source = await this.tours.readCopySource(body.source_trip_id, body.source_place_id);
// A tour with neither a line nor points has nothing a later tour could be made from.
if (!source || !copyStart(source)) throw tourNotFound();
return await this.kept.insertSnapshot(
keptTourSnapshot(source, { collectionId, userId, name: body.name ?? source.name, tripTitle: trip.title }),
);
});
await this.collections.notifyCollectionUsers(collectionId, socketId);
return { tour: keptTourItem(await this.found(collectionId, id)) };
}
/** Removes a kept tour from its collection; like a saved place, only its owner or an admin of it may. */
async remove(userId: number, collectionId: number, tourId: number, socketId?: string): Promise<void> {
await this.assertCollectionsOn();
const role = await this.collections.roleOf(userId, collectionId);
if (role === null) throw new DomainError(404, 'Collection not found', { code: 'collection_not_found' });
if (role !== 'owner' && role !== 'admin') {
throw new DomainError(403, 'Only an admin can remove tours from this list', { code: 'collection_role' });
}
if (!(await this.kept.deleteInCollection(collectionId, tourId))) throw tourNotFound();
await this.collections.notifyCollectionUsers(collectionId, socketId);
}
/**
* Makes a new tour of `tripId` from a kept tour of a collection the user can
* see. The caller has checked the trip and `place_edit` on it. The points
* come along only when asked for, in their order with their roles; left out,
* the new tour keeps the kept line as it is and has no points. A kept tour
* without a line cannot leave its points behind. The collection is not changed.
*/
async takeOver(
tripId: string,
userId: number,
body: CollectionTourTakeOverRequest,
socketId?: string,
): Promise<TourCreateResponse> {
const chosen = await this.readable(userId, body.collection_id, body.collection_tour_id);
if (!body.include_waypoints && chosen.route_geometry === null) {
throw new DomainError(409, 'This tour has no line yet, so its points have to come along', {
code: 'kept_tour_needs_points',
details: { code: 'kept_tour_needs_points' },
});
}
return await this.tours.createFromKeptTour(
tripId,
// Read again inside the write: a kept tour removed meanwhile leaves no half-made tour behind.
async () =>
keptTourCopySource(await this.found(body.collection_id, body.collection_tour_id), body.include_waypoints),
body.name ?? chosen.name,
{ tripTitle: chosen.source_trip_title, tourName: chosen.source_tour_name },
socketId,
);
}
/** A kept tour of a collection the user can see; the collection's own refusal when they cannot see it. */
private async readable(userId: number, collectionId: number, tourId: number): Promise<CollectionTourRow> {
await this.assertCollectionsOn();
await this.collections.assertAccess(userId, collectionId);
return await this.found(collectionId, tourId);
}
/** The kept tour, 404 when the collection holds no such tour. */
private async found(collectionId: number, tourId: number): Promise<CollectionTourRow> {
const row = await this.kept.findInCollection(collectionId, tourId);
if (!row) throw tourNotFound();
return row;
}
/** Every trip the user can open, the newest first, each saying whether they may add a tour to it. */
private async targets(user: CollectionTourUser): Promise<CollectionTourTarget[]> {
const ids = await this.membership.listAccessibleTripIds(user.id);
const trips = new Map((await this.kept.targetTrips(ids)).map((trip) => [trip.id, trip]));
const known = ids.flatMap((id) => {
const trip = trips.get(id);
return trip ? [trip] : [];
});
return await Promise.all(
known.map(async (trip) => ({
id: trip.id,
title: trip.title,
start_date: trip.start_date,
end_date: trip.end_date,
can_add: await this.permissions.checkPermission(
'place_edit',
user.role,
trip.user_id,
user.id,
trip.user_id !== user.id,
),
})),
);
}
/** The Tours addon gates the routes; a kept tour lives in a collection, so the Collections addon has to be on too. */
private async assertCollectionsOn(): Promise<void> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.COLLECTIONS))) {
throw new DomainError(404, 'Collections addon is not enabled', { code: 'addon_disabled' });
}
}
}
@@ -0,0 +1,6 @@
import { collectionTourSaveRequestSchema, collectionTourTakeOverRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class CollectionTourSaveDto extends createZodDto(collectionTourSaveRequestSchema) {}
export class CollectionTourTakeOverDto extends createZodDto(collectionTourTakeOverRequestSchema) {}
+184
View File
@@ -0,0 +1,184 @@
import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository';
import type { TourListRow, TourProvenanceRow, ToursRepository } from '../../db/repositories/Tours.repository';
import { parseRouteGeometry } from './tours.helpers';
import type {
BikeProfile,
PaddleBoatType,
PaddleLakeStyle,
PaddleWaterContext,
RunProfile,
SkiDiscipline,
SkiTechnique,
TourOrigin,
WinterActivity,
} from '@trek/shared';
/** The facet columns a copy carries over as its source has them, measures and settings alike. */
interface TourCopiedColumns {
tour_type: string;
distance: number | null;
elevation_gain: number | null;
elevation_loss: number | null;
duration: number | null;
planned_duration_minutes: number | null;
break_additional_minutes: number | null;
difficulty: string | null;
wanderer_ref: string | null;
match_confidence: number | null;
max_hiking_difficulty: number | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
hiking_preferences: string | null;
origin: string;
/** A copy of a recording was recorded when it was (ACT-01); the assignments stay with the tours they joined. */
track_started_at: string | null;
track_ended_at: string | null;
}
/** The facet a copy is made from: the columns it carries over and where the tour came from. */
interface TourCopyFacet extends TourCopiedColumns {
origin_file_name: string | null;
taken_over_from_trip_title: string | null;
taken_over_from_tour_name: string | null;
}
/** The columns a copy carries over, read off any row that has them, and nothing else of it. */
export function copiedColumns(row: TourCopiedColumns): TourCopiedColumns {
return {
tour_type: row.tour_type,
distance: row.distance,
elevation_gain: row.elevation_gain,
elevation_loss: row.elevation_loss,
duration: row.duration,
planned_duration_minutes: row.planned_duration_minutes,
break_additional_minutes: row.break_additional_minutes,
difficulty: row.difficulty,
wanderer_ref: row.wanderer_ref,
match_confidence: row.match_confidence,
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile,
winter_activity: row.winter_activity,
run_profile: row.run_profile,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline,
ski_technique: row.ski_technique,
boat_type: row.boat_type,
water_context: row.water_context,
paddle_speed_kmh: row.paddle_speed_kmh,
portage_speed_kmh: row.portage_speed_kmh,
paddle_lake_style: row.paddle_lake_style,
track_marks: row.track_marks,
hiking_preferences: row.hiking_preferences,
origin: row.origin,
track_started_at: row.track_started_at,
track_ended_at: row.track_ended_at,
};
}
/**
* What one copy of a tour is made from, whatever it is made into: a copy in
* its own trip (P2C-07), a take-over from another trip (LIB-02), a tour kept in
* a collection or a tour made from one (LIB-03). The one model every copy
* reads and writes, so the line, the type, the measures and the points never
* come apart on the way.
*/
export interface TourCopySource {
/** The tour of a trip it is read from; null for a tour kept in a collection, which belongs to no trip. */
place_id: number | null;
name: string;
description: string | null;
website: string | null;
/** The line as stored; null for a city walk saved with its stations only (CW-11). */
route_geometry: string | null;
facet: TourCopyFacet;
/** The control points in route order; empty where none come along. */
waypoints: TourWaypointRow[];
}
/** The trip and tour a copy says it was taken over from, as they were called then. */
export interface TakenOverFrom {
tripTitle: string;
tourName: string;
}
/** How a copy relates to where it came from. */
export interface TourCopyLinks {
/** The tour of the same trip the copy was planned from (P2C-07); null for any other copy. */
derivedFrom: number | null;
/** Within its own trip a copy keeps its points' links to the trip's places and the decisions about its obstacles. */
sameTrip: boolean;
/** Where the copy was taken over from; left out, it keeps what its source says. */
takenOverFrom?: TakenOverFrom;
}
/** A saved tour of a trip as a copy is made from it: its row, its provenance, its points. */
export function tourCopySourceOf(
row: TourListRow,
provenance: TourProvenanceRow,
waypoints: TourWaypointRow[],
): TourCopySource {
return {
place_id: row.place_id,
name: row.name,
description: row.description ?? null,
website: row.website ?? null,
route_geometry: provenance.route_geometry,
facet: {
...copiedColumns(row),
origin_file_name: provenance.origin_file_name,
taken_over_from_trip_title: provenance.taken_over_from_trip_title,
taken_over_from_tour_name: provenance.taken_over_from_tour_name,
},
waypoints,
};
}
/**
* Where a copy's place goes: the line's first point, or for a city walk saved
* with its stations only its first station. Undefined when there is neither,
* a source a copy cannot be made from.
*/
export function copyStart(source: Pick<TourCopySource, 'route_geometry' | 'waypoints'>): [number, number] | undefined {
if (source.route_geometry == null) {
const first = source.waypoints[0];
return first ? [first.lat, first.lng] : undefined;
}
const point = parseRouteGeometry(source.route_geometry)[0];
return point ? [point[0], point[1]] : undefined;
}
/** The facet row a copy is written with: every measure and setting as its source has it, its own place and links. */
export function copiedFacet(
source: TourCopySource,
placeId: number,
links: TourCopyLinks,
): Parameters<ToursRepository['insertTour']>[0] {
const { facet } = source;
return {
...facet,
place_id: placeId,
bike_profile: facet.bike_profile as BikeProfile | null,
winter_activity: facet.winter_activity as WinterActivity | null,
run_profile: facet.run_profile as RunProfile | null,
ski_discipline: facet.ski_discipline as SkiDiscipline | null,
ski_technique: facet.ski_technique as SkiTechnique | null,
boat_type: facet.boat_type as PaddleBoatType | null,
water_context: facet.water_context as PaddleWaterContext | null,
paddle_lake_style: facet.paddle_lake_style as PaddleLakeStyle | null,
origin: facet.origin as TourOrigin,
derived_from_place_id: links.derivedFrom,
// A copy of a copy names where the first one came from.
taken_over_from_trip_title: links.takenOverFrom?.tripTitle ?? facet.taken_over_from_trip_title,
taken_over_from_tour_name: links.takenOverFrom?.tourName ?? facet.taken_over_from_tour_name,
};
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { CollectionTours } from '../../db/entities/CollectionTours.entity';
import { Places } from '../../db/entities/Places.entity';
import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
@@ -10,6 +11,7 @@ import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
import { Tours } from '../../db/entities/Tours.entity';
import { AddonsModule } from '../addons/addons.module';
import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
import { CollectionsModule } from '../collections/collections.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
@@ -23,6 +25,9 @@ import { WandererModule } from '../wanderer/wanderer.module';
import { CityWalkBriefingService } from './city-walk-briefing.service';
import { CityWalkFootWayService } from './city-walk-foot-way.service';
import { CityWalkMcp } from './city-walk.mcp';
import { CollectionTourTakeOverController, CollectionToursController } from './collection-tours.controller';
import { CollectionToursMcp } from './collection-tours.mcp';
import { CollectionToursService } from './collection-tours.service';
import { TourActivitiesController } from './tour-activities.controller';
import { TourActivitiesMcp } from './tour-activities.mcp';
import { TourActivitiesService } from './tour-activities.service';
@@ -83,7 +88,9 @@ import { Module } from '@nestjs/common';
* TourActivitiesService, over the day assignments the visits repository reads.
* The user's own activity sums across their trips (STA-02) are read by
* TourActivityStatsService, over the trips TripMembershipModule says they can
* open, with the tours the user left out of them.
* open, with the tours the user left out of them. Whole tours kept in a
* collection (LIB-03) are this domain's own rows; the collection's roles and
* its live notice to its members come from CollectionsService.
*/
@Module({
imports: [
@@ -107,7 +114,10 @@ import { Module } from '@nestjs/common';
RoadtripModule,
// A saved change takes the stages a tour no longer has off their days (MDT-02).
AssignmentsDomainModule,
// A collection keeps whole tours (LIB-03): its roles decide who sees, keeps and removes one.
CollectionsModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
TourTypes,
TourWaypoints,
@@ -121,6 +131,8 @@ import { Module } from '@nestjs/common';
]),
],
controllers: [
// The static `from-collection` and `take-over` routes before ToursController's `:placeId`.
CollectionTourTakeOverController,
TourHorseRoutesController,
TourTakeOverController,
TourActivitiesController,
@@ -134,6 +146,7 @@ import { Module } from '@nestjs/common';
ToursImportController,
ToursWandererController,
TourLibraryController,
CollectionToursController,
],
providers: [
ToursService,
@@ -157,6 +170,7 @@ import { Module } from '@nestjs/common';
TourChangeUndoService,
TourActivitiesService,
TourActivityStatsService,
CollectionToursService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -165,6 +179,7 @@ import { Module } from '@nestjs/common';
TourChangeUndoMcp,
TourActivitiesMcp,
TourActivityStatsMcp,
CollectionToursMcp,
],
})
export class ToursModule {}
+72 -62
View File
@@ -30,6 +30,14 @@ import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers';
import {
copiedFacet,
copyStart,
tourCopySourceOf,
type TakenOverFrom,
type TourCopyLinks,
type TourCopySource,
} from './tour-copy.helpers';
import { readTourFile } from './tour-file-import.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision';
@@ -647,8 +655,8 @@ export class ToursService {
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
// Within the trip: the copy points at the tour it was planned from, keeps its
// stops' links to the trip's places and the decisions about its obstacles.
const copyId = await this.uow.transactional(() =>
this.insertCopyOf({ tid, pid }, { tid, name }, { derivedFrom: pid, sameTrip: true }),
const copyId = await this.uow.transactional(async () =>
this.insertCopy(await this.requireCopySource(tid, pid), { tid, name }, { derivedFrom: pid, sameTrip: true }),
);
return await this.announceCopy(tripId, copyId, socketId);
}
@@ -669,9 +677,9 @@ export class ToursService {
): Promise<TourCreateResponse> {
const tid = toRowId(tripId);
if (tid === null) throw new NotFoundException('Tour not found');
const copyId = await this.uow.transactional(() =>
this.insertCopyOf(
{ tid: source.tripId, pid: source.placeId },
const copyId = await this.uow.transactional(async () =>
this.insertCopy(
await this.requireCopySource(source.tripId, source.placeId),
{ tid, name },
{ derivedFrom: null, sameTrip: false, takenOverFrom: source },
),
@@ -679,82 +687,84 @@ export class ToursService {
return await this.announceCopy(tripId, copyId, socketId);
}
/**
* LIB-03: a tour of this trip made from a tour kept in a collection, the way
* a take-over makes one: its own id, the kept line, type, measures and
* settings, the points `read` hands over (none when they were left out) and
* no days. `read` runs inside the write, so a kept tour removed meanwhile
* leaves nothing behind. The new tour names the trip and tour the kept one
* was saved from.
*/
async createFromKeptTour(
tripId: string,
read: () => Promise<TourCopySource>,
name: string,
takenOverFrom: TakenOverFrom,
socketId?: string,
): Promise<TourCreateResponse> {
const tid = toRowId(tripId);
if (tid === null) throw new NotFoundException('Tour not found');
const copyId = await this.uow.transactional(async () =>
this.insertCopy(await read(), { tid, name }, { derivedFrom: null, sameTrip: false, takenOverFrom }),
);
return await this.announceCopy(tripId, copyId, socketId);
}
/**
* A saved tour of a trip as a copy is made from it, read whole: its line and
* texts, every measure and setting, where it came from and its control
* points. Undefined when the place is no tour of that trip.
*/
async readCopySource(tripId: number, placeId: number): Promise<TourCopySource | undefined> {
const [row, provenance, waypoints] = await Promise.all([
this.toursRepo.findInTrip(tripId, placeId),
this.toursRepo.findProvenance(tripId, placeId),
this.waypointsRepo.listForPlace(placeId),
]);
return row && provenance ? tourCopySourceOf(row, provenance, waypoints) : undefined;
}
private async requireCopySource(tripId: number, placeId: number): Promise<TourCopySource> {
const source = await this.readCopySource(tripId, placeId);
if (!source) throw new NotFoundException('Tour not found');
return source;
}
/**
* One copy of a tour: its place with the same line, its facet with every
* measure and setting, its control points. Inside the caller's transaction.
*/
private async insertCopyOf(
source: { tid: number; pid: number },
private async insertCopy(
source: TourCopySource,
target: { tid: number; name: string },
how: { derivedFrom: number | null; sameTrip: boolean; takenOverFrom?: { tripTitle: string; tourName: string } },
links: TourCopyLinks,
): Promise<number> {
const [row, provenance, place, waypoints] = await Promise.all([
this.toursRepo.findInTrip(source.tid, source.pid),
this.toursRepo.findProvenance(source.tid, source.pid),
this.placesRepo.findInTrip(source.pid, source.tid),
this.waypointsRepo.listForPlace(source.pid),
]);
// A city walk saved with its stations only (CW-11) is copied from its first one, its way still to plan;
// a line that holds no point has nothing to copy.
const start = place?.route_geometry == null ? firstPointOf(waypoints) : parseRouteGeometry(place.route_geometry)[0];
if (!row || !provenance || !place || !start) throw new NotFoundException('Tour not found');
if (!how.sameTrip) await this.assertTourTypeEnabled(row.tour_type);
// A line that holds no point, and a walk without line or stations, have nothing to copy.
const start = copyStart(source);
if (!start) throw new NotFoundException('Tour not found');
if (!links.sameTrip) await this.assertTourTypeEnabled(source.facet.tour_type);
const id = await this.placesRepo.insertTourPlace({
trip_id: target.tid,
name: target.name,
lat: start[0],
lng: start[1],
route_geometry: place.route_geometry ?? null,
description: place.description ?? null,
website: place.website ?? null,
});
await this.toursRepo.insertTour({
place_id: id,
tour_type: row.tour_type,
distance: row.distance,
elevation_gain: row.elevation_gain,
elevation_loss: row.elevation_loss,
duration: row.duration,
planned_duration_minutes: row.planned_duration_minutes,
break_additional_minutes: row.break_additional_minutes,
match_confidence: row.match_confidence,
max_hiking_difficulty: row.max_hiking_difficulty,
bike_profile: row.bike_profile as BikeProfile | null,
winter_activity: row.winter_activity as WinterActivity | null,
run_profile: row.run_profile as RunProfile | null,
run_pace_seconds_per_km: row.run_pace_seconds_per_km,
ski_discipline: row.ski_discipline as SkiDiscipline | null,
ski_technique: row.ski_technique as SkiTechnique | null,
boat_type: row.boat_type as PaddleBoatType | null,
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
portage_speed_kmh: row.portage_speed_kmh,
paddle_lake_style: row.paddle_lake_style as PaddleLakeStyle | null,
track_marks: row.track_marks,
hiking_preferences: row.hiking_preferences,
wanderer_ref: row.wanderer_ref,
difficulty: row.difficulty,
derived_from_place_id: how.derivedFrom,
origin: row.origin as TourOrigin,
origin_file_name: provenance.origin_file_name,
// A copy of a copy names where the first one came from.
taken_over_from_trip_title: how.takenOverFrom?.tripTitle ?? provenance.taken_over_from_trip_title,
taken_over_from_tour_name: how.takenOverFrom?.tourName ?? provenance.taken_over_from_tour_name,
// A copy of a recording was recorded when it was; the assignments stay with the tours they joined.
track_started_at: row.track_started_at,
track_ended_at: row.track_ended_at,
route_geometry: source.route_geometry,
description: source.description,
website: source.website,
});
await this.toursRepo.insertTour(copiedFacet(source, id, links));
// A stop standing for a place of the source trip stands for nothing in another trip.
await this.waypointsRepo.insertForPlace(
id,
how.sameTrip ? waypoints : waypoints.map((point) => ({ ...point, linked_place_id: null })),
links.sameTrip ? source.waypoints : source.waypoints.map((point) => ({ ...point, linked_place_id: null })),
);
if (how.sameTrip) {
if (source.place_id === null) return id;
if (links.sameTrip) {
// The copy keeps the decisions about its obstacles, bound to what they were decided on.
await this.toursRepo.copyObstacleReviews(source.pid, id);
await this.toursRepo.copyObstacleReviews(source.place_id, id);
} else {
// The import record says where the line itself came from, whichever trip holds it.
await this.toursRepo.copySource(source.pid, id);
await this.toursRepo.copySource(source.place_id, id);
}
return id;
}
@@ -0,0 +1,293 @@
/**
* Whole tours kept in a collection (LIB-03), through the real JwtAuthGuard,
* AddonGuard and TripAccessGuard against a migrated and seeded temp SQLite db,
* every answer checked against its response contract. The permission check is
* a container spy and the broadcasts go to a FakeRealtimeService.
*
* Fixtures: user 1 owns trips 5 and 6 and the collection, user 2 is a member
* of trip 5 whom the spy denies 'place_edit' and a viewer of the collection,
* user 3 has no access to either.
*/
import { CollectionTours } from '../../src/db/entities/CollectionTours.entity';
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { TourWaypoints } from '../../src/db/entities/TourWaypoints.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { ToursModule } from '../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { addCollectionMember, makeCollection } from '../helpers/factories/collections';
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const { journeyHooks } = vi.hoisted(() => ({
journeyHooks: {
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
},
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
let orm: TestOrm;
let checkPermission: MockInstance;
let collectionId: number;
const OWNER = 1;
const MEMBER = 2;
const OUTSIDER = 3;
const GEOMETRY = [
[47, 11, 600],
[47.01, 11.01, 700],
[47.02, 11.02, 650],
];
const tourBody = (overrides: Record<string, unknown> = {}) => ({
name: 'Ridge walk',
route_geometry: GEOMETRY,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Saddle' },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
...overrides,
});
describe('Collection tours e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
ToursModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(JourneyDomainService)
.useValue(journeyHooks)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const createTour = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
const res = await request(server).post(`/api/trips/${tripId}/tours`).set(as(OWNER)).send(body);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
const keep = async (placeId: number, userId = OWNER, extra: Record<string, unknown> = {}) =>
request(server)
.post(`/api/addons/collections/${collectionId}/tours`)
.set(as(userId))
.send({ source_trip_id: 5, source_place_id: placeId, ...extra });
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned ids: the session cookies sign for exactly these users.
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
await makeUser(orm, { id: 2, username: 'member', email: 'member@example.test', role: 'user', password_version: 0 });
await makeUser(orm, {
id: 3,
username: 'outsider',
email: 'outsider@example.test',
role: 'user',
password_version: 0,
});
await insertRows(orm, Trips, [
{ id: 5, user: 1, title: 'Alps' },
{ id: 6, user: 1, title: 'Other' },
]);
await addTripMember(orm, 5, 2);
const list = await makeCollection(orm, OWNER, { name: 'Hikes' });
collectionId = list.id;
await addCollectionMember(orm, collectionId, MEMBER, { role: 'viewer' });
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(async () => {
checkPermission.mockImplementation(
async (action: string, _role: string, _owner: number, userId: number) =>
!(action === 'place_edit' && userId === MEMBER),
);
await setAddonEnabled(orm, 'tours', true);
await setAddonEnabled(orm, 'collections', true);
realtime.broadcastMock.mockClear();
realtime.broadcastToUserMock.mockClear();
await deleteRows(orm, CollectionTours);
await deleteRows(orm, DayAssignments);
await deleteRows(orm, Places);
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('keeps a tour, lists it for every member and shows what taking it over asks about', async () => {
const source = await createTour();
const kept = await keep(source, OWNER, { name: 'Ridge, kept' });
expect(kept.status).toBe(201);
expect(kept.body.tour).toMatchObject({
name: 'Ridge, kept',
tour_type: 'hike',
has_line: true,
waypoint_count: 3,
source: { tripTitle: 'Alps', tourName: 'Ridge walk' },
saved_by: 'owner',
});
expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(
MEMBER,
{ type: 'collections:updated', collectionId },
undefined,
);
const listed = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(MEMBER));
expect(listed.status).toBe(200);
expect(listed.body.tours.map((tour: { id: number }) => tour.id)).toEqual([kept.body.tour.id]);
const detail = await request(server)
.get(`/api/addons/collections/${collectionId}/tours/${kept.body.tour.id}`)
.set(as(MEMBER));
expect(detail.status).toBe(200);
expect(detail.body.line).toEqual(GEOMETRY.map(([lat, lng]) => [lat, lng]));
expect(detail.body.points).toEqual([
{ role: 'start', name: null, shaping: false },
{ role: 'via', name: 'Saddle', shaping: false },
{ role: 'end', name: null, shaping: false },
]);
// The member sees the trip they belong to, without the right to add a tour to it.
expect(detail.body.trips).toEqual([{ id: 5, title: 'Alps', start_date: null, end_date: null, can_add: false }]);
});
it('takes a kept tour into a trip with its points, or with its line only, and changes neither source', async () => {
const source = await createTour();
const kept = (await keep(source)).body.tour as { id: number };
// A later change to the source does not reach the kept tour.
await request(server)
.put(`/api/trips/5/tours/${source}`)
.set(as(OWNER))
.send(tourBody({ name: 'Changed later' }));
const full = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true });
expect(full.status).toBe(201);
expect(full.body.tour).toMatchObject({ name: 'Ridge walk', planned: false, revision: 1 });
const roles = full.body.waypoints.map((point: { role: string; name?: string }) => [point.role, point.name ?? null]);
expect(roles).toEqual([
['start', null],
['via', 'Saddle'],
['end', null],
]);
expect(full.body.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge walk' });
expect(realtime.broadcastMock.mock.calls.every(([tripId]) => String(tripId) === '6')).toBe(true);
const bare = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: false, name: 'Bare' });
expect(bare.status).toBe(201);
expect(bare.body.tour).toMatchObject({ name: 'Bare', has_waypoints: false });
expect(await countRows(orm, TourWaypoints, { place: bare.body.tour.place_id })).toBe(0);
expect((await findRow(orm, Places, { id: bare.body.tour.place_id }))?.route_geometry).toBe(
JSON.stringify(GEOMETRY),
);
expect(await countRows(orm, Places, { trip: 6 })).toBe(2);
expect(await countRows(orm, DayAssignments)).toBe(0);
expect(await countRows(orm, CollectionTours)).toBe(1);
expect((await findRow(orm, Places, { id: source }))?.name).toBe('Changed later');
});
it('refuses a viewer keeping, a stranger reading, a trip without place_edit and an unsaid choice about the points', async () => {
const source = await createTour();
expect((await keep(source, MEMBER)).status).toBe(403);
const kept = (await keep(source)).body.tour as { id: number };
const stranger = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OUTSIDER));
expect(stranger.status).toBe(404);
expect(stranger.body).toEqual({ error: 'Collection not found' });
const body = { collection_id: collectionId, collection_tour_id: kept.id, include_waypoints: true };
const takeOver = (tripId: number, userId: number) =>
request(server).post(`/api/trips/${tripId}/tours/from-collection`).set(as(userId)).send(body);
expect((await takeOver(5, MEMBER)).status).toBe(403);
expect((await takeOver(6, OUTSIDER)).status).toBe(404);
const unsaid = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ collection_id: collectionId, collection_tour_id: kept.id });
expect(unsaid.status).toBe(400);
const wrongList = await request(server)
.post('/api/trips/6/tours/from-collection')
.set(as(OWNER))
.send({ ...body, collection_id: collectionId + 1000 });
expect(wrongList.status).toBe(404);
expect(await countRows(orm, Places, { trip: 6 })).toBe(0);
});
it('removes a kept tour for the owner only, and answers malformed ids, sessions and addons as the guards do', async () => {
const source = await createTour();
const kept = (await keep(source)).body.tour as { id: number };
const path = `/api/addons/collections/${collectionId}/tours/${kept.id}`;
expect((await request(server).delete(path).set(as(MEMBER))).status).toBe(403);
const removed = await request(server).delete(path).set(as(OWNER));
expect(removed.status).toBe(200);
expect(removed.body).toEqual({ success: true });
expect((await request(server).delete(path).set(as(OWNER))).status).toBe(404);
expect((await request(server).get(`/api/addons/collections/abc/tours`).set(as(OWNER))).status).toBe(404);
const malformed = await request(server).get(`/api/addons/collections/${collectionId}/tours/x`).set(as(OWNER));
expect(malformed.status).toBe(404);
expect((await request(server).get(`/api/addons/collections/${collectionId}/tours`)).status).toBe(401);
await setAddonEnabled(orm, 'collections', false);
const noCollections = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
expect(noCollections.body).toEqual({ error: 'Collections addon is not enabled' });
await setAddonEnabled(orm, 'tours', false);
const noTours = await request(server).get(`/api/addons/collections/${collectionId}/tours`).set(as(OWNER));
expect(noTours.status).toBe(404);
expect(noTours.body).toEqual({ error: 'Tours addon is not enabled' });
});
});
+4 -3
View File
@@ -43,14 +43,15 @@ describe('ALL_ENTITIES', () => {
* + tour_station_visits, how far a city walk got on one of its days
* + tour_change_steps, the changes to a tour saved from a confirmed proposal
* + tour_activities, the recorded tracks assigned to a tour
* + tour_activity_exclusions, the tours a user left out of their own activity sums)
* + tour_activity_exclusions, the tours a user left out of their own activity sums
* + collection_tours, the whole tours a collection keeps)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 146 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(146);
it('ENT-003: exactly 147 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(147);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -40,7 +40,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// only ever written whole, by the save of a confirmed change).
// + tour_activities: id, place_id, track_place_id (an assignment always names its tour and its recording).
// + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 510;
// + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name
// (a kept tour is only ever written whole, from the tour it was saved from).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 518;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -76,6 +76,7 @@ const UNNUMBERED = [
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
'Migration20200101046000_a_tour_waypoint_can_end_a_stage',
'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour',
'Migration20200101046100_a_collection_keeps_whole_tours',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,173 @@
/**
* `CollectionToursRepository` (LIB-03): the whole tours a collection keeps.
* A kept tour is written whole (CT3), listed newest first (CT1), read only
* within its collection (CT2) and removed only from it (CT4); the trips one
* could go into come with their owner (CT5).
*/
import { CollectionTours } from '../../../../src/db/entities/CollectionTours.entity';
import { Collections } from '../../../../src/db/entities/Collections.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import type {
CollectionTourInsert,
CollectionToursRepository,
} from '../../../../src/db/repositories/CollectionTours.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makeCollection } from '../../../helpers/factories/collections';
import { deleteRows, findRow, updateRows } from '../../../helpers/factories/rows';
import { makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: CollectionToursRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(CollectionTours);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const POINTS = [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Hut', key: 'stop-hut-1' },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
];
/** A kept hike with a line and three points, the middle one named, its points encoded as the service writes them. */
function snapshot(collectionId: number, savedBy: number | null, overrides: Partial<CollectionTourInsert> = {}) {
const row: CollectionTourInsert = {
collection_id: collectionId,
saved_by: savedBy,
name: 'Ridge',
description: 'Up high',
website: null,
route_geometry: '[[47,11,600],[47.01,11.01,650],[47.02,11.02,630]]',
waypoints: JSON.stringify(POINTS),
tour_type: 'hike',
distance: 4.2,
elevation_gain: 300,
elevation_loss: null,
duration: 90,
planned_duration_minutes: null,
break_additional_minutes: 20,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 3,
bike_profile: null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
hiking_preferences: null,
origin: 'trek',
origin_file_name: null,
source_trip_title: 'Alps',
source_tour_name: 'Ridge',
...overrides,
};
return row;
}
async function fixture() {
const { user } = await makeUser(t, { username: 'keeper' });
const mine = await makeCollection(t, user.id, { name: 'Hikes' });
const other = await makeCollection(t, user.id, { name: 'Other' });
return { user, mine, other };
}
describe('CollectionToursRepository', () => {
it('CT3/CT2: keeps a snapshot whole and reads it back with its points decoded and who saved it', async () => {
const { user, mine, other } = await fixture();
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
const row = await repo.findInCollection(mine.id, id);
expect(row).toMatchObject({
id,
collection_id: mine.id,
saved_by: user.id,
saved_by_name: 'keeper',
name: 'Ridge',
tour_type: 'hike',
distance: 4.2,
elevation_loss: null,
source_trip_title: 'Alps',
});
expect(row?.waypoints).toEqual(POINTS);
expect(typeof row?.created_at).toBe('string');
// Only within its own collection.
expect(await repo.findInCollection(other.id, id)).toBeUndefined();
});
it('CT1: lists a collection’s tours newest first, the id breaking ties, and nothing of another', async () => {
const { user, mine, other } = await fixture();
const first = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'First' }));
const second = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'Second' }));
const older = await repo.insertSnapshot(snapshot(mine.id, user.id, { name: 'Older' }));
await repo.insertSnapshot(snapshot(other.id, user.id, { name: 'Elsewhere' }));
await updateRows(t, CollectionTours, { id: older }, { created_at: '2020-01-01 00:00:00' });
await updateRows(t, CollectionTours, { id: { $in: [first, second] } }, { created_at: '2026-10-20 10:00:00' });
const rows = await repo.listForCollection(mine.id);
expect(rows.map((row) => row.name)).toEqual(['Second', 'First', 'Older']);
});
it('CT1/CT2: an account that is gone leaves the kept tour with nobody named', async () => {
const { mine } = await fixture();
const { user: gone } = await makeUser(t, { username: 'gone' });
const id = await repo.insertSnapshot(snapshot(mine.id, gone.id));
await deleteRows(t, Users, { id: gone.id });
expect(await repo.findInCollection(mine.id, id)).toMatchObject({ saved_by: null, saved_by_name: null });
});
it('CT2: a broken point list, or one of another shape, reads as no points, the line kept as it is', async () => {
const { user, mine } = await fixture();
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
await updateRows(t, CollectionTours, { id }, { waypoints: 'not json' });
const row = await repo.findInCollection(mine.id, id);
expect(row?.waypoints).toEqual([]);
expect(row?.route_geometry).toContain('47.01');
await updateRows(t, CollectionTours, { id }, { waypoints: JSON.stringify([{ lat: 147, lng: 11 }]) });
expect((await repo.listForCollection(mine.id))[0]?.waypoints).toEqual([]);
});
it('CT4: removes a kept tour only from its own collection', async () => {
const { user, mine, other } = await fixture();
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
expect(await repo.deleteInCollection(other.id, id)).toBe(false);
expect(await findRow(t, CollectionTours, { id })).not.toBeNull();
expect(await repo.deleteInCollection(mine.id, id)).toBe(true);
expect(await repo.findInCollection(mine.id, id)).toBeUndefined();
});
it('CT5: names the trips asked for with their owner and dates, and none for no ids', async () => {
const { user } = await fixture();
const alps = await makeTrip(t, user.id, { title: 'Alps', start_date: '2026-07-01', end_date: '2026-07-08' });
const coast = await makeTrip(t, user.id, { title: 'Coast', start_date: null, end_date: null });
await makeTrip(t, user.id, { title: 'Not asked' });
const rows = await repo.targetTrips([coast.id, alps.id]);
expect([...rows].sort((a, b) => a.id - b.id)).toEqual([
{ id: alps.id, user_id: user.id, title: 'Alps', start_date: '2026-07-01', end_date: '2026-07-08' },
{ id: coast.id, user_id: user.id, title: 'Coast', start_date: null, end_date: null },
]);
expect(await repo.targetTrips([])).toEqual([]);
});
it('goes with its collection', async () => {
const { user, mine } = await fixture();
const id = await repo.insertSnapshot(snapshot(mine.id, user.id));
await deleteRows(t, Collections, { id: mine.id });
expect(await findRow(t, CollectionTours, { id })).toBeNull();
});
});
@@ -0,0 +1,116 @@
/**
* CollectionToursController and CollectionTourTakeOverController (LIB-03):
* route metadata, the guards and the addon they run behind, the permission
* the take-over needs, and the delegation to CollectionToursService, a
* malformed id answering 404 before the service is asked.
*/
import 'reflect-metadata';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { DomainError } from '../../../src/nest/common/domain-error';
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import {
CollectionTourTakeOverController,
CollectionToursController,
} from '../../../src/nest/tours/collection-tours.controller';
import type { CollectionToursService } from '../../../src/nest/tours/collection-tours.service';
import type { User } from '../../../src/types';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest';
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
function fakeService() {
return {
list: vi.fn().mockResolvedValue({ tours: [] }),
detail: vi.fn().mockResolvedValue({ tour: {}, line: [], points: [], trips: [] }),
save: vi.fn().mockResolvedValue({ tour: { id: 4 } }),
remove: vi.fn().mockResolvedValue(undefined),
takeOver: vi.fn().mockResolvedValue({ tour: { place_id: 9 }, waypoints: [] }),
};
}
function route(controller: object, method: string) {
const handler = (controller as Record<string, unknown>)[method] as object;
return {
path: Reflect.getMetadata(PATH_METADATA, handler) as string,
verb: Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod,
permission: Reflect.getMetadata(TRIP_PERMISSION_KEY, handler) as string | undefined,
};
}
async function notFound(work: () => Promise<unknown>): Promise<string> {
try {
await work();
} catch (err) {
expect(err).toBeInstanceOf(DomainError);
expect((err as DomainError).getStatus()).toBe(404);
return (err as DomainError).publicMessage;
}
throw new Error('expected a 404');
}
describe('CollectionToursController', () => {
it('LIB3-CTRL-001: lives under the collection, behind the Tours addon and a session, without a trip guard', () => {
expect(Reflect.getMetadata(PATH_METADATA, CollectionToursController)).toBe(
'api/addons/collections/:collectionId/tours',
);
expect(Reflect.getMetadata(GUARDS_METADATA, CollectionToursController)).toEqual([AddonGuard, JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, CollectionToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
const proto = CollectionToursController.prototype;
expect(route(proto, 'list')).toMatchObject({ path: '/', verb: RequestMethod.GET });
expect(route(proto, 'save')).toMatchObject({ path: '/', verb: RequestMethod.POST });
expect(route(proto, 'detail')).toMatchObject({ path: ':tourId', verb: RequestMethod.GET });
expect(route(proto, 'remove')).toMatchObject({ path: ':tourId', verb: RequestMethod.DELETE });
});
it('LIB3-CTRL-002: hands the user, the ids and the socket on to the service', async () => {
const service = fakeService();
const controller = new CollectionToursController(service as unknown as CollectionToursService);
const body = { source_trip_id: 5, source_place_id: 7 };
await controller.list(user, '3');
await controller.save(user, '3', body, 'sock');
await controller.detail(user, '3', '4');
expect(await controller.remove(user, '3', '4', 'sock')).toEqual({ success: true });
expect(service.list).toHaveBeenCalledWith(2, 3);
expect(service.save).toHaveBeenCalledWith(2, 3, body, 'sock');
expect(service.detail).toHaveBeenCalledWith(user, 3, 4);
expect(service.remove).toHaveBeenCalledWith(2, 3, 4, 'sock');
});
it('LIB3-CTRL-003: a malformed id is a missing collection or tour, never a 500', async () => {
const service = fakeService();
const controller = new CollectionToursController(service as unknown as CollectionToursService);
expect(await notFound(() => controller.list(user, 'abc'))).toBe('Collection not found');
expect(await notFound(() => controller.detail(user, '3', '1e3'))).toBe('Tour not found');
expect(await notFound(() => controller.remove(user, '-1', '4'))).toBe('Collection not found');
expect(service.list).not.toHaveBeenCalled();
expect(service.detail).not.toHaveBeenCalled();
expect(service.remove).not.toHaveBeenCalled();
});
});
describe('CollectionTourTakeOverController', () => {
it('LIB3-CTRL-004: is trip-scoped, needs place_edit and hands the body on', async () => {
expect(Reflect.getMetadata(PATH_METADATA, CollectionTourTakeOverController)).toBe('api/trips/:tripId/tours');
expect(Reflect.getMetadata(GUARDS_METADATA, CollectionTourTakeOverController)).toEqual([
AddonGuard,
JwtAuthGuard,
TripAccessGuard,
]);
expect(route(CollectionTourTakeOverController.prototype, 'takeOver')).toEqual({
path: 'from-collection',
verb: RequestMethod.POST,
permission: 'place_edit',
});
const service = fakeService();
const controller = new CollectionTourTakeOverController(service as unknown as CollectionToursService);
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect(await controller.takeOver('6', user, body, 'sock')).toEqual({ tour: { place_id: 9 }, waypoints: [] });
expect(service.takeOver).toHaveBeenCalledWith('6', 2, body, 'sock');
});
});
@@ -0,0 +1,155 @@
/**
* The pure halves of LIB-03 and the copy model it shares with P2C-07 and
* LIB-02: where a copy starts, what a kept tour shows and what a tour made
* from it carries.
*/
import type { CollectionTourRow } from '../../../src/db/repositories/CollectionTours.repository';
import {
keptTourCopySource,
keptTourItem,
keptTourLine,
keptTourPoints,
keptTourSnapshot,
} from '../../../src/nest/tours/collection-tours.helpers';
import { copiedFacet, copyStart } from '../../../src/nest/tours/tour-copy.helpers';
import { describe, expect, it } from 'vitest';
const row: CollectionTourRow = {
id: 4,
collection_id: 9,
saved_by: 1,
saved_by_name: 'owner',
name: 'Ridge',
description: null,
website: 'https://example.test/ridge',
route_geometry: '[[47,11,600],[47.02,11.02,630]]',
waypoints: [
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 2 },
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'via', sequence: 1, name: 'Hut', shaping: true },
],
tour_type: 'bike',
distance: 12,
elevation_gain: null,
elevation_loss: null,
duration: null,
planned_duration_minutes: 120,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
bike_profile: 'gravel',
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
hiking_preferences: null,
origin: 'gpx',
origin_file_name: 'ridge.gpx',
source_trip_title: 'Alps',
source_tour_name: 'Ridge',
created_at: '2026-10-20 10:00:00',
};
describe('collection tour helpers (LIB-03)', () => {
it('LIB3-HLP-001: a copy starts at the line, a walk without one at its first station, nothing without either', () => {
expect(copyStart({ route_geometry: '[[47,11,600],[48,12]]', waypoints: [] })).toEqual([47, 11]);
expect(copyStart({ route_geometry: null, waypoints: [{ lat: 1, lng: 2, role: 'start', sequence: 0 }] })).toEqual([
1, 2,
]);
expect(copyStart({ route_geometry: null, waypoints: [] })).toBeUndefined();
expect(copyStart({ route_geometry: '[]', waypoints: [] })).toBeUndefined();
});
it('LIB3-HLP-002: lists a kept tour as it was saved, unknown measures staying unknown', () => {
expect(keptTourItem(row)).toMatchObject({
id: 4,
tour_type: 'bike',
distance: 12,
elevation_gain: null,
origin: 'gpx',
has_line: true,
waypoint_count: 3,
station_count: null,
source: { tripTitle: 'Alps', tourName: 'Ridge' },
saved_by: 'owner',
saved_at: '2026-10-20 10:00:00',
});
expect(keptTourItem({ ...row, route_geometry: null }).has_line).toBe(false);
});
it('LIB3-HLP-003: shows the points in route order and the line thinned for drawing', () => {
expect(keptTourPoints(row)).toEqual([
{ role: 'start', name: null, shaping: false },
{ role: 'via', name: 'Hut', shaping: true },
{ role: 'end', name: null, shaping: false },
]);
expect(keptTourLine(row)).toEqual([
[47, 11],
[47.02, 11.02],
]);
const long = JSON.stringify(Array.from({ length: 900 }, (_, i) => [47 + i / 1000, 11]));
expect(keptTourLine({ route_geometry: long })).toHaveLength(400);
expect(keptTourLine({ route_geometry: null })).toEqual([]);
});
it('LIB3-HLP-004: a tour made from it carries everything, its points only when asked for', () => {
const withPoints = keptTourCopySource(row, true);
expect(withPoints).toMatchObject({ place_id: null, name: 'Ridge', website: 'https://example.test/ridge' });
expect(withPoints.waypoints).toHaveLength(3);
expect(keptTourCopySource(row, false).waypoints).toEqual([]);
expect(keptTourCopySource(row, false).route_geometry).toBe(row.route_geometry);
expect(copiedFacet(withPoints, 77, { derivedFrom: null, sameTrip: false })).toMatchObject({
place_id: 77,
tour_type: 'bike',
bike_profile: 'gravel',
planned_duration_minutes: 120,
origin: 'gpx',
origin_file_name: 'ridge.gpx',
derived_from_place_id: null,
taken_over_from_trip_title: 'Alps',
taken_over_from_tour_name: 'Ridge',
});
expect(
copiedFacet(withPoints, 77, {
derivedFrom: null,
sameTrip: false,
takenOverFrom: { tripTitle: 'Coast', tourName: 'Cliffs' },
}),
).toMatchObject({ taken_over_from_trip_title: 'Coast', taken_over_from_tour_name: 'Cliffs' });
});
it('LIB3-HLP-005: keeping one leaves its links to the trip behind and names where it came from', () => {
const source = keptTourCopySource(
{
...row,
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0, linked_place_id: 3 },
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
],
},
true,
);
const snapshot = keptTourSnapshot(source, { collectionId: 9, userId: 1, name: 'Kept', tripTitle: 'Coast' });
expect(snapshot).toMatchObject({
collection_id: 9,
saved_by: 1,
name: 'Kept',
bike_profile: 'gravel',
origin_file_name: 'ridge.gpx',
source_trip_title: 'Coast',
source_tour_name: 'Ridge',
});
const kept = JSON.parse(snapshot.waypoints) as Array<{ linked_place_id?: number | null }>;
expect(kept.map((point) => point.linked_place_id)).toEqual([null, null]);
});
it('LIB3-HLP-006: a point that is no control point refuses the whole snapshot', () => {
const broken = keptTourCopySource({ ...row, waypoints: [{ lat: 147, lng: 11, role: 'start', sequence: 0 }] }, true);
expect(() => keptTourSnapshot(broken, { collectionId: 9, userId: 1, name: 'Kept', tripTitle: 'Coast' })).toThrow();
});
});
@@ -0,0 +1,238 @@
/**
* CollectionToursMcp (LIB-03): the tours a collection keeps, over the same
* service and the same checks as REST. The suite takes its subject from the
* container over a snapshot test db; the addon gate is the registry's.
*/
import { CollectionTours } from '../../../src/db/entities/CollectionTours.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import { CollectionToursMcp } from '../../../src/nest/tours/collection-tours.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { addCollectionMember, makeCollection } from '../../helpers/factories/collections';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow } from '../../helpers/factories/rows';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { NotFoundException } from '@nestjs/common';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let orm: TestOrm;
let mcp: CollectionToursMcp;
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(CollectionToursMcp);
orm = await createTestOrm(db);
});
beforeEach(async () => {
resetTestDb(db);
orm.clear();
vi.restoreAllMocks();
await setAddonEnabled(orm, 'collections', true);
});
afterAll(async () => {
await orm.close();
await t.close();
db.close();
});
const ctx = (userId: number) => ({ userId }) as McpContext;
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
const text = result.content[0]?.text ?? '';
return result.isError ? { error: text } : JSON.parse(text);
}
async function fixture() {
const { user } = await makeUser(orm);
const { user: viewer } = await makeUser(orm);
const { user: outsider } = await makeUser(orm);
const alps = await makeTrip(orm, user.id, { title: 'Alps' });
const next = await makeTrip(orm, user.id, { title: 'Next' });
const ridge = await makePlace(orm, alps.id, {
name: 'Ridge',
route_geometry: JSON.stringify([
[47, 11, 600],
[47.01, 11.01, 650],
]),
});
await makeTour(orm, ridge.id, { distance: 3.5 });
await addTourWaypoints(orm, ridge.id, [
{ lat: 47, lng: 11 },
{ lat: 47.01, lng: 11.01 },
]);
const list = await makeCollection(orm, user.id, { name: 'Hikes' });
await addCollectionMember(orm, list.id, viewer.id, { role: 'viewer' });
return { user, viewer, outsider, alps, next, ridge, list };
}
describe('CollectionToursMcp', () => {
it('LIB3-MCP-001: keeps a tour, lists it and reads it with the trips it could go into', async () => {
const f = await fixture();
const saved = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
expect(saved.tour).toMatchObject({
name: 'Ridge',
distance: 3.5,
source: { tripTitle: 'Alps', tourName: 'Ridge' },
});
const listed = read(await mcp.listCollectionTours({ collection_id: f.list.id }, ctx(f.viewer.id)));
expect(listed.tours.map((tour: { id: number }) => tour.id)).toEqual([saved.tour.id]);
const detail = read(
await mcp.getCollectionTour({ collection_id: f.list.id, collection_tour_id: saved.tour.id }, ctx(f.user.id)),
);
expect(detail.points).toEqual([
{ role: 'start', name: null, shaping: false },
{ role: 'end', name: null, shaping: false },
]);
expect(detail.trips.map((trip: { title: string }) => trip.title).sort()).toEqual(['Alps', 'Next']);
});
it('LIB3-MCP-002: an admin may add to every trip; the role is read for the check', async () => {
const f = await fixture();
const admin = vi.spyOn(t.get(McpToolGuardsService), 'isAdminUser').mockResolvedValue(true);
const saved = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
const detail = read(
await mcp.getCollectionTour({ collection_id: f.list.id, collection_tour_id: saved.tour.id }, ctx(f.user.id)),
);
expect(admin).toHaveBeenCalledWith(f.user.id);
expect(detail.trips.every((trip: { can_add: boolean }) => trip.can_add)).toBe(true);
});
it('LIB3-MCP-003: takes a kept tour into a trip with or without its points', async () => {
const f = await fixture();
const { tour } = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
const base = { tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id };
const withPoints = read(await mcp.takeOverCollectionTour({ ...base, include_waypoints: true }, ctx(f.user.id)));
expect(withPoints.waypoints).toHaveLength(2);
expect(withPoints.provenance.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge' });
const lineOnly = read(
await mcp.takeOverCollectionTour({ ...base, include_waypoints: false, name: 'Bare' }, ctx(f.user.id)),
);
expect(lineOnly.tour.name).toBe('Bare');
expect(lineOnly.tour.has_waypoints).toBe(false);
expect(await findRow(orm, Places, { id: lineOnly.tour.place_id })).toMatchObject({ trip_id: f.next.id });
});
it('LIB3-MCP-004: refuses a trip out of reach, a trip without place_edit and a list out of sight', async () => {
const f = await fixture();
const { tour } = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
const body = { collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true };
expect(read(await mcp.takeOverCollectionTour({ ...body, tripId: f.next.id }, ctx(f.outsider.id)))).toEqual({
error: 'Trip not found or access denied.',
});
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
const refused = await mcp.takeOverCollectionTour({ ...body, tripId: f.next.id }, ctx(f.user.id));
expect(refused.isError).toBe(true);
expect(denied).toHaveBeenCalledWith('place_edit', f.next.id, f.user.id);
denied.mockRestore();
const own = await makeTrip(orm, f.outsider.id);
// A service refusal reaches the session through the registry's error mapper.
await expect(mcp.takeOverCollectionTour({ ...body, tripId: own.id }, ctx(f.outsider.id))).rejects.toThrow(
'Collection not found',
);
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(0);
});
it('LIB3-MCP-005: a tour the copy model cannot make is not found', async () => {
const f = await fixture();
const { tour } = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
vi.spyOn(t.get(ToursService), 'createFromKeptTour').mockRejectedValueOnce(new NotFoundException('Tour not found'));
expect(
read(
await mcp.takeOverCollectionTour(
{ tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true },
ctx(f.user.id),
),
),
).toEqual({ error: 'Tour not found.' });
vi.spyOn(t.get(ToursService), 'createFromKeptTour').mockRejectedValueOnce(new Error('boom'));
await expect(
mcp.takeOverCollectionTour(
{ tripId: f.next.id, collection_id: f.list.id, collection_tour_id: tour.id, include_waypoints: true },
ctx(f.user.id),
),
).rejects.toThrow('boom');
});
it('LIB3-MCP-006: the owner removes a kept tour, a viewer may not', async () => {
const f = await fixture();
const { tour } = read(
await mcp.saveTourToCollection(
{ collection_id: f.list.id, source_trip_id: f.alps.id, source_place_id: f.ridge.id },
ctx(f.user.id),
),
);
await expect(
mcp.deleteCollectionTour({ collection_id: f.list.id, collection_tour_id: tour.id }, ctx(f.viewer.id)),
).rejects.toThrow('Only an admin can remove tours from this list');
expect(
read(await mcp.deleteCollectionTour({ collection_id: f.list.id, collection_tour_id: tour.id }, ctx(f.user.id))),
).toEqual({ success: true });
expect(await countRows(orm, CollectionTours)).toBe(0);
});
it('LIB3-MCP-007: the tools are there only while both addons are on, and only for the scopes they need', async () => {
const options = (name: string) => {
const entry = getEntry(CollectionToursMcp as unknown as ClassRef, name);
if (entry?.kind !== 'tool') throw new Error(`${name} is no tool`);
return entry.options;
};
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(true);
const gate = options('listCollectionTours').when;
expect(await gate?.(ctx(1), mcp)).toBe(true);
enabled.mockImplementation(async (id: string) => id !== 'collections');
expect(await gate?.(ctx(1), mcp)).toBe(false);
enabled.mockImplementation(async (id: string) => id !== 'tours');
expect(await gate?.(ctx(1), mcp)).toBe(false);
const allowed = (name: string, scopes: string[]) => {
const access = options(name).access;
return typeof access === 'function' ? access({ userId: 1, scopes } as McpContext) : access;
};
expect(allowed('getCollectionTour', ['collections:read', 'trips:read'])).toBe(true);
expect(allowed('getCollectionTour', ['collections:read'])).toBe(false);
expect(allowed('saveTourToCollection', ['collections:write', 'trips:read'])).toBe(true);
expect(allowed('saveTourToCollection', ['collections:read', 'trips:read'])).toBe(false);
expect(allowed('takeOverCollectionTour', ['trips:write', 'collections:read'])).toBe(true);
expect(allowed('takeOverCollectionTour', ['trips:write'])).toBe(false);
expect(allowed('deleteCollectionTour', [])).toEqual({ group: 'collections', mode: 'write' });
});
});
@@ -0,0 +1,402 @@
/**
* CollectionToursService (LIB-03): whole tours kept in a collection and taken
* into a trip again, over the container and a snapshot test db. A kept tour is
* an independent snapshot made through the tours' one copy model; the
* collection's roles decide who reads, keeps and removes one, and nothing is
* kept in step afterwards.
*/
import { CollectionTours } from '../../../src/db/entities/CollectionTours.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { CollectionToursRepository } from '../../../src/db/repositories/CollectionTours.repository';
import { DomainError } from '../../../src/nest/common/domain-error';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { CollectionToursService } from '../../../src/nest/tours/collection-tours.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { addCollectionMember, makeCollection } from '../../helpers/factories/collections';
import { makePlace } from '../../helpers/factories/places';
import { countRows, deleteRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { addTourWaypoints, makeTour } from '../../helpers/factories/tours';
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { resetTestDb } from '../../helpers/test-db';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { getRepositoryToken } from '@mikro-orm/nestjs';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let orm: TestOrm;
let service: CollectionToursService;
const LINE = [
[47, 11, 600],
[47.01, 11.01, 650],
[47.02, 11.02, 630],
];
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
service = t.get(CollectionToursService);
orm = await createTestOrm(db);
});
beforeEach(async () => {
resetTestDb(db);
orm.clear();
vi.restoreAllMocks();
t.realtime.broadcastToUserMock.mockClear();
await setAddonEnabled(orm, 'collections', true);
});
afterAll(async () => {
await orm.close();
await t.close();
db.close();
});
/** Status and text of a refusal. */
async function refusal(work: Promise<unknown>): Promise<{ status: number; error: string }> {
try {
await work;
} catch (err) {
if (err instanceof DomainError) return { status: err.getStatus(), error: err.publicMessage };
const http = err as { getStatus?: () => number; message: string };
return { status: http.getStatus?.() ?? 500, error: http.message };
}
throw new Error('expected a refusal');
}
/**
* The owner's trips Alps (with a hike of three points, one standing for a
* place of the trip) and Next, someone else's trip Away the owner is a member
* of, a collection with an editor and a viewer, and an outsider.
*/
async function fixture() {
const { user: owner } = await makeUser(orm, { username: 'owner' });
const { user: editor } = await makeUser(orm, { username: 'editor' });
const { user: viewer } = await makeUser(orm, { username: 'viewer' });
const { user: outsider } = await makeUser(orm, { username: 'outsider' });
const alps = await makeTrip(orm, owner.id, { title: 'Alps' });
const next = await makeTrip(orm, owner.id, { title: 'Next' });
const away = await makeTrip(orm, outsider.id, { title: 'Away' });
await addTripMember(orm, away.id, owner.id);
const hut = await makePlace(orm, alps.id, { name: 'Hut', lat: 47.01, lng: 11.01 });
const ridge = await makePlace(orm, alps.id, {
name: 'Ridge',
lat: 47,
lng: 11,
description: 'Up high',
route_geometry: JSON.stringify(LINE),
});
await makeTour(orm, ridge.id, { distance: 4.2, elevation_gain: 300, max_hiking_difficulty: 3, duration: 90 });
await addTourWaypoints(orm, ridge.id, [
{ lat: 47, lng: 11 },
{ lat: 47.01, lng: 11.01, name: 'Hut', linkedPlace: hut.id, shaping: 0 },
{ lat: 47.015, lng: 11.015, shaping: 1 },
{ lat: 47.02, lng: 11.02 },
]);
const list = await makeCollection(orm, owner.id, { name: 'Hikes' });
await addCollectionMember(orm, list.id, editor.id, { role: 'editor' });
await addCollectionMember(orm, list.id, viewer.id, { role: 'viewer' });
return { owner, editor, viewer, outsider, alps, next, away, hut, ridge, list };
}
async function keep(f: Awaited<ReturnType<typeof fixture>>, name?: string) {
const { tour } = await service.save(f.owner.id, f.list.id, {
source_trip_id: f.alps.id,
source_place_id: f.ridge.id,
...(name ? { name } : {}),
});
return tour;
}
describe('CollectionToursService', () => {
it('LIB3-SVC-001: keeps a whole independent snapshot, its points in order, their links to the trip left behind', async () => {
const f = await fixture();
const tour = await keep(f);
expect(tour).toMatchObject({
collection_id: f.list.id,
name: 'Ridge',
tour_type: 'hike',
distance: 4.2,
elevation_gain: 300,
elevation_loss: null,
max_hiking_difficulty: 3,
origin: 'trek',
has_line: true,
waypoint_count: 4,
source: { tripTitle: 'Alps', tourName: 'Ridge' },
saved_by: 'owner',
});
const stored = await findRow(orm, CollectionTours, { id: tour.id });
expect(stored?.route_geometry).toBe(JSON.stringify(LINE));
const points = JSON.parse(stored?.waypoints ?? '[]') as Array<Record<string, unknown>>;
expect(points.map((point) => [point.role, point.sequence])).toEqual([
['start', 0],
['via', 1],
['via', 2],
['end', 3],
]);
expect(points.every((point) => point.linked_place_id == null)).toBe(true);
// Every member of the collection hears of it, the one who kept it included.
expect(t.realtime.broadcastToUserMock.mock.calls.map(([userId]) => userId).sort()).toEqual(
[f.owner.id, f.editor.id, f.viewer.id].sort(),
);
// Later changes to the tour it came from do not reach it.
await updateRows(orm, Places, { id: f.ridge.id }, { name: 'Renamed', route_geometry: '[[1,1],[2,2]]' });
await deleteRows(orm, TourWaypoints, { place: f.ridge.id });
const [listed] = (await service.list(f.viewer.id, f.list.id)).tours;
expect(listed).toMatchObject({ name: 'Ridge', waypoint_count: 4, has_line: true });
// And the trip it came from is as it was, beyond what the test changed itself.
expect(await countRows(orm, Places, { trip: f.alps.id })).toBe(2);
});
it('LIB3-SVC-002: keeps it under another name, an editor may keep one too', async () => {
const f = await fixture();
const { tour } = await service.save(f.editor.id, f.list.id, {
source_trip_id: f.alps.id,
source_place_id: f.ridge.id,
name: 'Ridge, spring',
});
expect(tour).toMatchObject({ name: 'Ridge, spring', source: { tripTitle: 'Alps', tourName: 'Ridge' } });
});
it('LIB3-SVC-003: refuses a viewer, a stranger, a trip out of reach and a place of another trip, writing nothing', async () => {
const f = await fixture();
const body = { source_trip_id: f.alps.id, source_place_id: f.ridge.id };
expect(await refusal(service.save(f.viewer.id, f.list.id, body))).toEqual({
status: 403,
error: 'You have read-only access to this list',
});
expect(await refusal(service.save(f.outsider.id, f.list.id, body))).toEqual({
status: 404,
error: 'Collection not found',
});
const own = await makeCollection(orm, f.outsider.id);
expect(await refusal(service.save(f.outsider.id, own.id, body))).toEqual({ status: 404, error: 'Tour not found' });
expect(
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.next.id, source_place_id: f.ridge.id })),
).toEqual({ status: 404, error: 'Tour not found' });
expect(
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.alps.id, source_place_id: f.hut.id })),
).toEqual({ status: 404, error: 'Tour not found' });
expect(await countRows(orm, CollectionTours)).toBe(0);
});
it('LIB3-SVC-004: lists for every member, the newest first, and refuses whoever cannot see the list', async () => {
const f = await fixture();
const first = await keep(f, 'First');
const second = await keep(f, 'Second');
await updateRows(orm, CollectionTours, { id: first.id }, { created_at: '2020-01-01 00:00:00' });
expect((await service.list(f.viewer.id, f.list.id)).tours.map((tour) => tour.id)).toEqual([second.id, first.id]);
expect(await refusal(service.list(f.outsider.id, f.list.id))).toEqual({
status: 404,
error: 'Collection not found',
});
});
it('LIB3-SVC-005: the detail has the line, the points in order and every trip, each saying whether a tour may go there', async () => {
const f = await fixture();
const tour = await keep(f);
vi.spyOn(t.get(PermissionsService), 'checkPermission').mockImplementation(
async (_action: string, _role: string, tripOwner: number | null, userId: number) => tripOwner === userId,
);
const detail = await service.detail({ id: f.owner.id, role: 'user' }, f.list.id, tour.id);
expect(detail.line).toEqual(LINE.map(([lat, lng]) => [lat, lng]));
expect(detail.points).toEqual([
{ role: 'start', name: null, shaping: false },
{ role: 'via', name: 'Hut', shaping: false },
{ role: 'via', name: null, shaping: true },
{ role: 'end', name: null, shaping: false },
]);
expect(detail.trips.map((trip) => [trip.title, trip.can_add]).sort()).toEqual([
['Alps', true],
['Away', false],
['Next', true],
]);
expect(await refusal(service.detail({ id: f.owner.id, role: 'user' }, f.list.id, 999999))).toEqual({
status: 404,
error: 'Tour not found',
});
});
it('LIB3-SVC-006: takes a kept tour into a trip with its points, as a tour of its own with no days', async () => {
const f = await fixture();
const kept = await keep(f);
const result = await service.takeOver(String(f.next.id), f.editor.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: true,
});
const copy = result.tour.place_id;
expect(copy).not.toBe(f.ridge.id);
expect(result.tour).toMatchObject({ name: 'Ridge', tour_type: 'hike', distance: 4.2, revision: 1, planned: false });
expect(result.provenance?.takenOverFrom).toEqual({ tripTitle: 'Alps', tourName: 'Ridge' });
expect(result.waypoints.map((point) => [point.role, point.sequence, point.shaping === true])).toEqual([
['start', 0, false],
['via', 1, false],
['via', 2, true],
['end', 3, false],
]);
expect(result.waypoints.every((point) => point.linked_place_id == null)).toBe(true);
expect(await findRow(orm, Places, { id: copy })).toMatchObject({
trip_id: f.next.id,
route_geometry: JSON.stringify(LINE),
});
expect(await countRows(orm, DayAssignments, { place: copy })).toBe(0);
// Only the tour itself is new in the trip, no places for its points.
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(1);
// The kept tour and the tour it was saved from are as they were.
expect((await service.list(f.owner.id, f.list.id)).tours).toHaveLength(1);
expect(await countRows(orm, TourWaypoints, { place: f.ridge.id })).toBe(4);
});
it('LIB3-SVC-007: without its points the new tour keeps the kept line as it is and has no points at all', async () => {
const f = await fixture();
const kept = await keep(f);
const result = await service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: false,
name: 'Line only',
});
const copy = result.tour.place_id;
expect(result.tour.name).toBe('Line only');
expect(await countRows(orm, TourWaypoints, { place: copy })).toBe(0);
expect((await findRow(orm, Places, { id: copy }))?.route_geometry).toBe(JSON.stringify(LINE));
expect(await countRows(orm, Places, { trip: f.next.id })).toBe(1);
expect((await findRows(orm, Tours, { place: copy }))[0]).toMatchObject({ distance: 4.2, elevation_gain: 300 });
});
it('LIB3-SVC-008: a kept walk without a line cannot leave its points behind, and nothing is written', async () => {
const f = await fixture();
const walk = await makePlace(orm, f.alps.id, { name: 'Old town', lat: 48.1, lng: 11.5 });
await makeTour(orm, walk.id, { tourTypeRef: 'walk' });
await addTourWaypoints(orm, walk.id, [
{ lat: 48.1, lng: 11.5, station_key: 'stop-gate-1', name: 'Gate', stay_minutes: 20 },
{ lat: 48.11, lng: 11.51, station_key: 'stop-dome-1', name: 'Dome' },
]);
const { tour: kept } = await service.save(f.owner.id, f.list.id, {
source_trip_id: f.alps.id,
source_place_id: walk.id,
});
expect(kept).toMatchObject({
has_line: false,
station_count: 2,
station_stay_minutes: 20,
station_stays_unknown: 1,
});
const before = await countRows(orm, Places);
expect(
await refusal(
service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: false,
}),
),
).toEqual({ status: 409, error: 'This tour has no line yet, so its points have to come along' });
expect(await countRows(orm, Places)).toBe(before);
// With its points it comes over as a walk still waiting for its way.
const result = await service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: true,
});
expect(result.tour).toMatchObject({ tour_type: 'walk', route_pending: true });
expect(result.waypoints.map((point) => point.name)).toEqual(['Gate', 'Dome']);
});
it('LIB3-SVC-009: refuses a list out of sight, a tour of another list, and a tour removed while it is made', async () => {
const f = await fixture();
const kept = await keep(f);
const other = await makeCollection(orm, f.owner.id, { name: 'Other' });
const body = { collection_id: f.list.id, collection_tour_id: kept.id, include_waypoints: true };
expect(await refusal(service.takeOver(String(f.away.id), f.outsider.id, body))).toEqual({
status: 404,
error: 'Collection not found',
});
expect(
await refusal(service.takeOver(String(f.next.id), f.owner.id, { ...body, collection_id: other.id })),
).toEqual({ status: 404, error: 'Tour not found' });
// Gone between the check and the write: the write leaves no half-made tour behind.
const repo = t.moduleRef.get<CollectionToursRepository>(getRepositoryToken(CollectionTours), { strict: false });
const find = repo.findInCollection.bind(repo);
vi.spyOn(repo, 'findInCollection')
.mockImplementationOnce(find)
.mockResolvedValueOnce(undefined);
const before = await countRows(orm, Places);
expect(await refusal(service.takeOver(String(f.next.id), f.owner.id, body))).toEqual({
status: 404,
error: 'Tour not found',
});
expect(await countRows(orm, Places)).toBe(before);
});
it('LIB3-SVC-010: a tour type switched off since cannot be taken into a trip', async () => {
const f = await fixture();
const kept = await keep(f);
await updateRows(orm, CollectionTours, { id: kept.id }, { tourTypeRef: 'food' });
const before = await countRows(orm, Places);
const refused = await refusal(
service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: true,
}),
);
expect(refused.status).toBe(400);
expect(await countRows(orm, Places)).toBe(before);
});
it('LIB3-SVC-011: only the owner or an admin of the list removes a kept tour; tours made from it stay', async () => {
const f = await fixture();
const kept = await keep(f);
const made = await service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: true,
});
expect(await refusal(service.remove(f.editor.id, f.list.id, kept.id))).toEqual({
status: 403,
error: 'Only an admin can remove tours from this list',
});
t.realtime.broadcastToUserMock.mockClear();
await service.remove(f.owner.id, f.list.id, kept.id);
expect(t.realtime.broadcastToUserMock).toHaveBeenCalled();
expect(await countRows(orm, CollectionTours)).toBe(0);
expect(await findRow(orm, Places, { id: made.tour.place_id })).not.toBeNull();
expect(await refusal(service.remove(f.owner.id, f.list.id, kept.id))).toEqual({
status: 404,
error: 'Tour not found',
});
});
it('LIB3-SVC-012: with the Collections addon off there is nothing to read, keep or take', async () => {
const f = await fixture();
const kept = await keep(f);
await setAddonEnabled(orm, 'collections', false);
const off = { status: 404, error: 'Collections addon is not enabled' };
expect(await refusal(service.list(f.owner.id, f.list.id))).toEqual(off);
expect(
await refusal(service.save(f.owner.id, f.list.id, { source_trip_id: f.alps.id, source_place_id: f.ridge.id })),
).toEqual(off);
expect(await refusal(service.remove(f.owner.id, f.list.id, kept.id))).toEqual(off);
expect(
await refusal(
service.takeOver(String(f.next.id), f.owner.id, {
collection_id: f.list.id,
collection_tour_id: kept.id,
include_waypoints: true,
}),
),
).toEqual(off);
});
});
+50
View File
@@ -1368,5 +1368,55 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'الجولة كاملة',
'tours.stages.totalPartial': 'معروف لـ {known} من {of} مراحل',
'tours.stages.openInPlanner': 'فتح هذه المرحلة في المخطط',
'tours.collection.save': 'الاحتفاظ في مجموعة',
'tours.collection.saveHint': 'يحتفظ بنسخة مستقلة من هذه الجولة في إحدى مجموعاتك',
'tours.collection.saveFirst': 'احفظ الجولة أولًا لتحتفظ بها في مجموعة',
'tours.collection.saveSub': 'نسخة مستقلة. التغييرات اللاحقة على هذه الجولة لا تصل إليها.',
'tours.collection.list': 'المجموعة',
'tours.collection.readOnly': 'يمكنك قراءة هذه القائمة فقط',
'tours.collection.noLists': 'ليست لديك أي مجموعة بعد.',
'tours.collection.openCollections': 'فتح المجموعات',
'tours.collection.nameThere': 'الاسم في المجموعة',
'tours.collection.keep': 'احتفظ',
'tours.collection.kept': 'أصبحت «{name}» محفوظة في «{list}».',
'tours.collection.keepFailed': 'تعذّر الاحتفاظ بالجولة.',
'tours.collection.offline': 'تحتاج المجموعات إلى اتصال.',
'tours.collection.listsFailed': 'تعذّر تحميل المجموعات.',
'tours.collection.toursFailed': 'تعذّر تحميل جولات هذه القائمة.',
'tours.collection.points': 'نقاط الطريق: {count}',
'tours.collection.points.zero': 'نقاط الطريق: {count}',
'tours.collection.points.one': 'نقطة طريق واحدة',
'tours.collection.points.two': 'نقطتا طريق',
'tours.collection.points.few': '{count} نقاط طريق',
'tours.collection.points.many': '{count} نقطة طريق',
'tours.collection.savedFrom': 'محفوظة من',
'tours.collection.source': 'محفوظة من «{tour}» في «{trip}»',
'tours.collection.keptBy': 'احتفظ بها {name}',
'tours.collection.add': 'إضافة إلى رحلة',
'tours.collection.addHint': 'ينشئ جولة جديدة مستقلة في الرحلة التي تختارها',
'tours.collection.remove': 'إزالة من المجموعة',
'tours.collection.removeTitle': 'هل تريد إزالة هذه الجولة من المجموعة؟',
'tours.collection.removeBody': 'الجولات التي أُنشئت منها في رحلاتك تبقى كما هي.',
'tours.collection.removed': 'أُزيلت الجولة من المجموعة.',
'tours.collection.removeFailed': 'تعذّرت إزالة الجولة.',
'tours.collection.takeEyebrow': 'من المجموعة',
'tours.collection.takeSub': 'تصبح جولة مستقلة في الرحلة التي تختارها، بلا أيام. وتبقى المجموعة كما هي.',
'tours.collection.intoTrip': 'إلى الرحلة',
'tours.collection.noPermission': 'لا يمكنك إضافة جولات إلى هذه الرحلة',
'tours.collection.noTrips': 'ليست لديك رحلة تضيفها إليها بعد.',
'tours.collection.withPoints': 'مع نقاط الطريق: {count}',
'tours.collection.withPoints.zero': 'مع نقاط الطريق: {count}',
'tours.collection.withPoints.one': 'مع نقطة طريق واحدة',
'tours.collection.withPoints.two': 'مع نقطتي طريق',
'tours.collection.withPoints.few': 'مع {count} نقاط طريق',
'tours.collection.withPoints.many': 'مع {count} نقطة طريق',
'tours.collection.withPointsHint': 'البداية والتوقفات والنهاية تأتي بترتيبها.',
'tours.collection.lineOnly': 'الخط فقط',
'tours.collection.lineOnlyHint': 'يبقى الخط كما حُفظ. لا تُضاف نقاط طريق.',
'tours.collection.needsPoints': 'ليس لهذه الجولة خط بعد، لذا يجب أن تأتي نقاط طريقها معها.',
'tours.collection.nameInTrip': 'الاسم في الرحلة',
'tours.collection.taken': 'أصبحت «{name}» جولة في «{trip}».',
'tours.collection.takeFailed': 'تعذّرت إضافة الجولة إلى الرحلة.',
'tours.collection.openTrip': 'فتح الرحلة',
};
export default tours;
+42
View File
@@ -1308,5 +1308,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Bütün tur',
'tours.stages.totalPartial': '{of} mərhələdən {known} üçün məlumdur',
'tours.stages.openInPlanner': 'Bu mərhələni planlayıcıda aç',
'tours.collection.save': 'Kolleksiyada saxla',
'tours.collection.saveHint': 'Bu turun müstəqil surətini kolleksiyalarınızdan birində saxlayır',
'tours.collection.saveFirst': 'Kolleksiyada saxlamaq üçün əvvəlcə turu yadda saxlayın',
'tours.collection.saveSub': 'Müstəqil surət. Bu turda sonrakı dəyişikliklər ona çatmır.',
'tours.collection.list': 'Kolleksiya',
'tours.collection.readOnly': 'Bu siyahını yalnız oxuya bilərsiniz',
'tours.collection.noLists': 'Hələ heç bir kolleksiyanız yoxdur.',
'tours.collection.openCollections': 'Kolleksiyaları aç',
'tours.collection.nameThere': 'Kolleksiyadakı ad',
'tours.collection.keep': 'Saxla',
'tours.collection.kept': '“{name}” indi “{list}” kolleksiyasındadır.',
'tours.collection.keepFailed': 'Tur saxlanıla bilmədi.',
'tours.collection.offline': 'Kolleksiyalar üçün bağlantı lazımdır.',
'tours.collection.listsFailed': 'Kolleksiyalar yüklənə bilmədi.',
'tours.collection.toursFailed': 'Bu siyahının turları yüklənə bilmədi.',
'tours.collection.points': '{count} yol nöqtəsi',
'tours.collection.points.one': '{count} yol nöqtəsi',
'tours.collection.savedFrom': 'Haradan saxlanılıb',
'tours.collection.source': '“{trip}” səfərindəki “{tour}” turundan saxlanılıb',
'tours.collection.keptBy': 'Saxlayan: {name}',
'tours.collection.add': 'Səfərə əlavə et',
'tours.collection.addHint': 'Seçdiyiniz səfərdə öz yeni turunu yaradır',
'tours.collection.remove': 'Kolleksiyadan çıxar',
'tours.collection.removeTitle': 'Bu tur kolleksiyadan çıxarılsın?',
'tours.collection.removeBody': 'Səfərlərinizdə ondan artıq yaradılmış turlar olduğu kimi qalır.',
'tours.collection.removed': 'Tur kolleksiyadan çıxarıldı.',
'tours.collection.removeFailed': 'Tur çıxarıla bilmədi.',
'tours.collection.takeEyebrow': 'Kolleksiyadan',
'tours.collection.takeSub': 'Seçdiyiniz səfərdə günlərsiz öz turu olur. Kolleksiya olduğu kimi qalır.',
'tours.collection.intoTrip': 'Səfərə',
'tours.collection.noPermission': 'Bu səfərə tur əlavə edə bilməzsiniz',
'tours.collection.noTrips': 'Hələ onu əlavə etmək üçün səfəriniz yoxdur.',
'tours.collection.withPoints': '{count} yol nöqtəsi ilə',
'tours.collection.withPoints.one': '{count} yol nöqtəsi ilə',
'tours.collection.withPointsHint': 'Başlanğıc, dayanacaqlar və son öz sırası ilə gəlir.',
'tours.collection.lineOnly': 'Yalnız xətt',
'tours.collection.lineOnlyHint': 'Xətt saxlanıldığı kimi qalır. Yol nöqtəsi əlavə olunmur.',
'tours.collection.needsPoints': 'Bu turun hələ xətti yoxdur, ona görə yol nöqtələri də gəlməlidir.',
'tours.collection.nameInTrip': 'Səfərdəki ad',
'tours.collection.taken': '“{name}” indi “{trip}” səfərinin turudur.',
'tours.collection.takeFailed': 'Tur səfərə əlavə edilə bilmədi.',
'tours.collection.openTrip': 'Səfəri aç',
};
export default tours;
+42
View File
@@ -1321,5 +1321,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Roteiro completo',
'tours.stages.totalPartial': 'conhecido para {known} de {of} etapas',
'tours.stages.openInPlanner': 'Abrir esta etapa no planejador',
'tours.collection.save': 'Guardar em uma coleção',
'tours.collection.saveHint': 'Guarda uma cópia independente deste passeio em uma das suas coleções',
'tours.collection.saveFirst': 'Salve o passeio primeiro para guardá-lo em uma coleção',
'tours.collection.saveSub': 'Uma cópia independente. Mudanças posteriores neste passeio não chegam a ela.',
'tours.collection.list': 'Coleção',
'tours.collection.readOnly': 'Você só pode ler esta lista',
'tours.collection.noLists': 'Você ainda não tem nenhuma coleção.',
'tours.collection.openCollections': 'Abrir coleções',
'tours.collection.nameThere': 'Nome na coleção',
'tours.collection.keep': 'Guardar',
'tours.collection.kept': '“{name}” agora está guardado em “{list}”.',
'tours.collection.keepFailed': 'Não foi possível guardar o passeio.',
'tours.collection.offline': 'As coleções precisam de conexão.',
'tours.collection.listsFailed': 'Não foi possível carregar as coleções.',
'tours.collection.toursFailed': 'Não foi possível carregar os passeios desta lista.',
'tours.collection.points': '{count} pontos de rota',
'tours.collection.points.one': '{count} ponto de rota',
'tours.collection.savedFrom': 'Guardado de',
'tours.collection.source': 'Guardado de “{tour}” em “{trip}”',
'tours.collection.keptBy': 'Guardado por {name}',
'tours.collection.add': 'Adicionar a uma viagem',
'tours.collection.addHint': 'Cria um passeio novo e próprio na viagem que você escolher',
'tours.collection.remove': 'Tirar da coleção',
'tours.collection.removeTitle': 'Tirar este passeio da coleção?',
'tours.collection.removeBody': 'Os passeios já criados a partir dele nas suas viagens ficam como estão.',
'tours.collection.removed': 'O passeio foi tirado da coleção.',
'tours.collection.removeFailed': 'Não foi possível tirar o passeio.',
'tours.collection.takeEyebrow': 'Da coleção',
'tours.collection.takeSub': 'Vira um passeio próprio na viagem escolhida, sem dias. A coleção fica como está.',
'tours.collection.intoTrip': 'Na viagem',
'tours.collection.noPermission': 'Você não pode adicionar passeios a esta viagem',
'tours.collection.noTrips': 'Você ainda não tem nenhuma viagem para adicioná-lo.',
'tours.collection.withPoints': 'Com {count} pontos de rota',
'tours.collection.withPoints.one': 'Com {count} ponto de rota',
'tours.collection.withPointsHint': 'Início, paradas e fim vêm na sua ordem.',
'tours.collection.lineOnly': 'Só a linha',
'tours.collection.lineOnlyHint': 'A linha fica como foi salva. Nenhum ponto de rota é adicionado.',
'tours.collection.needsPoints': 'Este passeio ainda não tem linha, então os pontos de rota precisam vir junto.',
'tours.collection.nameInTrip': 'Nome na viagem',
'tours.collection.taken': '“{name}” agora é um passeio de “{trip}”.',
'tours.collection.takeFailed': 'Não foi possível adicionar o passeio à viagem.',
'tours.collection.openTrip': 'Abrir viagem',
};
export default tours;
+42
View File
@@ -1328,5 +1328,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Ruta sencera',
'tours.stages.totalPartial': 'conegut per a {known} de {of} etapes',
'tours.stages.openInPlanner': 'Obre aquesta etapa al planificador',
'tours.collection.save': 'Desar en una col·lecció',
'tours.collection.saveHint': "Desa una còpia independent d'aquesta ruta en una de les teves col·leccions",
'tours.collection.saveFirst': 'Desa primer la ruta per guardar-la en una col·lecció',
'tours.collection.saveSub': "Una còpia independent. Els canvis posteriors d'aquesta ruta no hi arriben.",
'tours.collection.list': 'Col·lecció',
'tours.collection.readOnly': 'Només pots llegir aquesta llista',
'tours.collection.noLists': 'Encara no tens cap col·lecció.',
'tours.collection.openCollections': 'Obre les col·leccions',
'tours.collection.nameThere': 'Nom a la col·lecció',
'tours.collection.keep': 'Desa',
'tours.collection.kept': '«{name}» ara és a «{list}».',
'tours.collection.keepFailed': "No s'ha pogut desar la ruta.",
'tours.collection.offline': 'Les col·leccions necessiten connexió.',
'tours.collection.listsFailed': "No s'han pogut carregar les col·leccions.",
'tours.collection.toursFailed': "No s'han pogut carregar les rutes d'aquesta llista.",
'tours.collection.points': '{count} punts de ruta',
'tours.collection.points.one': '{count} punt de ruta',
'tours.collection.savedFrom': 'Desada des de',
'tours.collection.source': 'Desada des de «{tour}» a «{trip}»',
'tours.collection.keptBy': 'Desada per {name}',
'tours.collection.add': 'Afegeix a un viatge',
'tours.collection.addHint': 'Crea una ruta nova i pròpia al viatge que triïs',
'tours.collection.remove': 'Treu de la col·lecció',
'tours.collection.removeTitle': 'Vols treure aquesta ruta de la col·lecció?',
'tours.collection.removeBody': "Les rutes ja creades a partir d'aquesta als teus viatges es queden com són.",
'tours.collection.removed': "La ruta s'ha tret de la col·lecció.",
'tours.collection.removeFailed': "No s'ha pogut treure la ruta.",
'tours.collection.takeEyebrow': 'Des de la col·lecció',
'tours.collection.takeSub': 'Esdevé una ruta pròpia del viatge que triïs, sense dies. La col·lecció es queda com és.',
'tours.collection.intoTrip': 'Al viatge',
'tours.collection.noPermission': 'No pots afegir rutes a aquest viatge',
'tours.collection.noTrips': 'Encara no tens cap viatge on afegir-la.',
'tours.collection.withPoints': 'Amb {count} punts de ruta',
'tours.collection.withPoints.one': 'Amb {count} punt de ruta',
'tours.collection.withPointsHint': "L'inici, les parades i el final venen en el seu ordre.",
'tours.collection.lineOnly': 'Només la línia',
'tours.collection.lineOnlyHint': "La línia es queda com es va desar. No s'hi afegeix cap punt de ruta.",
'tours.collection.needsPoints': 'Aquesta ruta encara no té línia, per això els seus punts de ruta han de venir.',
'tours.collection.nameInTrip': 'Nom al viatge',
'tours.collection.taken': '«{name}» ara és una ruta de «{trip}».',
'tours.collection.takeFailed': "No s'ha pogut afegir la ruta al viatge.",
'tours.collection.openTrip': 'Obre el viatge',
};
export default tours;
+44
View File
@@ -1313,5 +1313,49 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Celá trasa',
'tours.stages.totalPartial': 'známo pro {known} z {of} etap',
'tours.stages.openInPlanner': 'Otevřít tuto etapu v plánovači',
'tours.collection.save': 'Uložit do sbírky',
'tours.collection.saveHint': 'Uloží nezávislou kopii této túry do jedné z tvých sbírek',
'tours.collection.saveFirst': 'Nejprve túru ulož, abys ji mohl dát do sbírky',
'tours.collection.saveSub': 'Nezávislá kopie. Pozdější změny této túry se jí nedotknou.',
'tours.collection.list': 'Sbírka',
'tours.collection.readOnly': 'Tento seznam můžeš jen číst',
'tours.collection.noLists': 'Zatím nemáš žádnou sbírku.',
'tours.collection.openCollections': 'Otevřít sbírky',
'tours.collection.nameThere': 'Název ve sbírce',
'tours.collection.keep': 'Uložit',
'tours.collection.kept': '„{name}“ je teď ve sbírce „{list}“.',
'tours.collection.keepFailed': 'Túru se nepodařilo uložit.',
'tours.collection.offline': 'Sbírky potřebují připojení.',
'tours.collection.listsFailed': 'Sbírky se nepodařilo načíst.',
'tours.collection.toursFailed': 'Túry tohoto seznamu se nepodařilo načíst.',
'tours.collection.points': '{count} bodů trasy',
'tours.collection.points.one': '{count} bod trasy',
'tours.collection.points.few': '{count} body trasy',
'tours.collection.savedFrom': 'Uloženo z',
'tours.collection.source': 'Uloženo z „{tour}“ v „{trip}“',
'tours.collection.keptBy': 'Uložil(a) {name}',
'tours.collection.add': 'Přidat do cesty',
'tours.collection.addHint': 'Vytvoří vlastní novou túru v cestě, kterou zvolíš',
'tours.collection.remove': 'Odebrat ze sbírky',
'tours.collection.removeTitle': 'Odebrat tuto túru ze sbírky?',
'tours.collection.removeBody': 'Túry, které už z ní ve tvých cestách vznikly, zůstanou, jak jsou.',
'tours.collection.removed': 'Túra byla odebrána ze sbírky.',
'tours.collection.removeFailed': 'Túru se nepodařilo odebrat.',
'tours.collection.takeEyebrow': 'Ze sbírky',
'tours.collection.takeSub': 'Stane se vlastní túrou zvolené cesty, bez dnů. Sbírka zůstane, jak je.',
'tours.collection.intoTrip': 'Do cesty',
'tours.collection.noPermission': 'Do této cesty nemůžeš přidávat túry',
'tours.collection.noTrips': 'Zatím nemáš žádnou cestu, kam ji přidat.',
'tours.collection.withPoints': 'S {count} body trasy',
'tours.collection.withPoints.one': 'S {count} bodem trasy',
'tours.collection.withPoints.few': 'Se {count} body trasy',
'tours.collection.withPointsHint': 'Start, zastávky a cíl přejdou ve svém pořadí.',
'tours.collection.lineOnly': 'Jen linie',
'tours.collection.lineOnlyHint': 'Linie zůstane, jak byla uložena. Žádné body trasy se nepřidají.',
'tours.collection.needsPoints': 'Tato túra zatím nemá linii, proto musí její body trasy přejít s ní.',
'tours.collection.nameInTrip': 'Název v cestě',
'tours.collection.taken': '„{name}“ je teď túrou cesty „{trip}“.',
'tours.collection.takeFailed': 'Túru se nepodařilo přidat do cesty.',
'tours.collection.openTrip': 'Otevřít cestu',
};
export default tours;
+43
View File
@@ -1339,6 +1339,49 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Ganze Tour',
'tours.stages.totalPartial': 'bekannt für {known} von {of} Etappen',
'tours.stages.openInPlanner': 'Diese Etappe im Planer öffnen',
'tours.collection.save': 'In einer Sammlung behalten',
'tours.collection.saveHint': 'Legt eine unabhängige Kopie dieser Tour in einer deiner Sammlungen ab',
'tours.collection.saveFirst': 'Speichere die Tour zuerst, um sie in einer Sammlung zu behalten',
'tours.collection.saveSub': 'Eine unabhängige Kopie. Spätere Änderungen an dieser Tour erreichen sie nicht.',
'tours.collection.list': 'Sammlung',
'tours.collection.readOnly': 'Diese Liste kannst du nur lesen',
'tours.collection.noLists': 'Du hast noch keine Sammlung.',
'tours.collection.openCollections': 'Sammlungen öffnen',
'tours.collection.nameThere': 'Name in der Sammlung',
'tours.collection.keep': 'Behalten',
'tours.collection.kept': '„{name}“ liegt jetzt in „{list}“.',
'tours.collection.keepFailed': 'Die Tour konnte nicht abgelegt werden.',
'tours.collection.offline': 'Sammlungen brauchen eine Verbindung.',
'tours.collection.listsFailed': 'Die Sammlungen konnten nicht geladen werden.',
'tours.collection.toursFailed': 'Die Touren dieser Liste konnten nicht geladen werden.',
'tours.collection.points': '{count} Wegpunkte',
'tours.collection.points.one': '{count} Wegpunkt',
'tours.collection.savedFrom': 'Abgelegt aus',
'tours.collection.source': 'Abgelegt aus „{tour}“ in „{trip}“',
'tours.collection.keptBy': 'Abgelegt von {name}',
'tours.collection.add': 'In eine Reise übernehmen',
'tours.collection.addHint': 'Legt in einer Reise deiner Wahl eine eigene neue Tour an',
'tours.collection.remove': 'Aus der Sammlung entfernen',
'tours.collection.removeTitle': 'Diese Tour aus der Sammlung entfernen?',
'tours.collection.removeBody': 'Touren, die schon daraus in deinen Reisen entstanden sind, bleiben, wie sie sind.',
'tours.collection.removed': 'Die Tour wurde aus der Sammlung entfernt.',
'tours.collection.removeFailed': 'Die Tour konnte nicht entfernt werden.',
'tours.collection.takeEyebrow': 'Aus der Sammlung',
'tours.collection.takeSub':
'Wird in der gewählten Reise eine eigene Tour, ohne Tage. Die Sammlung bleibt, wie sie ist.',
'tours.collection.intoTrip': 'In die Reise',
'tours.collection.noPermission': 'Du darfst dieser Reise keine Touren hinzufügen',
'tours.collection.noTrips': 'Du hast noch keine Reise, in die sie passt.',
'tours.collection.withPoints': 'Mit {count} Wegpunkten',
'tours.collection.withPoints.one': 'Mit {count} Wegpunkt',
'tours.collection.withPointsHint': 'Start, Zwischenpunkte und Ziel kommen in ihrer Reihenfolge mit.',
'tours.collection.lineOnly': 'Nur die Linie',
'tours.collection.lineOnlyHint': 'Die Linie bleibt, wie sie gespeichert wurde. Es kommen keine Wegpunkte hinzu.',
'tours.collection.needsPoints': 'Diese Tour hat noch keine Linie, darum müssen ihre Wegpunkte mitkommen.',
'tours.collection.nameInTrip': 'Name in der Reise',
'tours.collection.taken': '„{name}“ ist jetzt eine Tour von „{trip}“.',
'tours.collection.takeFailed': 'Die Tour konnte nicht in die Reise übernommen werden.',
'tours.collection.openTrip': 'Reise öffnen',
};
export default tours;
+43
View File
@@ -1295,5 +1295,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Whole tour',
'tours.stages.totalPartial': 'known for {known} of {of} stages',
'tours.stages.openInPlanner': 'Open this stage in the planner',
'tours.collection.save': 'Keep in a collection',
'tours.collection.saveHint': 'Keeps an independent copy of this tour in one of your collections',
'tours.collection.saveFirst': 'Save the tour first to keep it in a collection',
'tours.collection.saveSub': 'An independent copy. Later changes to this tour do not reach it.',
'tours.collection.list': 'Collection',
'tours.collection.readOnly': 'You can only read this list',
'tours.collection.noLists': 'You have no collection yet.',
'tours.collection.openCollections': 'Open collections',
'tours.collection.nameThere': 'Name in the collection',
'tours.collection.keep': 'Keep',
'tours.collection.kept': '“{name}” is now kept in “{list}”.',
'tours.collection.keepFailed': 'The tour could not be kept.',
'tours.collection.offline': 'Collections need a connection.',
'tours.collection.listsFailed': 'The collections could not be loaded.',
'tours.collection.toursFailed': 'The tours of this list could not be loaded.',
'tours.collection.points': '{count} waypoints',
'tours.collection.points.one': '{count} waypoint',
'tours.collection.savedFrom': 'Saved from',
'tours.collection.source': 'Saved from “{tour}” in “{trip}”',
'tours.collection.keptBy': 'Kept by {name}',
'tours.collection.add': 'Add to a trip',
'tours.collection.addHint': 'Makes a new tour of its own in a trip you choose',
'tours.collection.remove': 'Remove from the collection',
'tours.collection.removeTitle': 'Remove this tour from the collection?',
'tours.collection.removeBody': 'Tours already made from it in your trips stay as they are.',
'tours.collection.removed': 'The tour was removed from the collection.',
'tours.collection.removeFailed': 'The tour could not be removed.',
'tours.collection.takeEyebrow': 'From the collection',
'tours.collection.takeSub':
'Becomes a tour of its own in the trip you choose, without days. The collection stays as it is.',
'tours.collection.intoTrip': 'Into the trip',
'tours.collection.noPermission': 'You may not add tours to this trip',
'tours.collection.noTrips': 'You have no trip to add it to yet.',
'tours.collection.withPoints': 'With {count} waypoints',
'tours.collection.withPoints.one': 'With {count} waypoint',
'tours.collection.withPointsHint': 'Start, stops and end come along in their order.',
'tours.collection.lineOnly': 'Line only',
'tours.collection.lineOnlyHint': 'The line stays as saved. No waypoints are added.',
'tours.collection.needsPoints': 'This tour has no line yet, so its waypoints have to come along.',
'tours.collection.nameInTrip': 'Name in the trip',
'tours.collection.taken': '“{name}” is now a tour of “{trip}”.',
'tours.collection.takeFailed': 'The tour could not be added to the trip.',
'tours.collection.openTrip': 'Open trip',
};
export default tours;
+43
View File
@@ -1320,5 +1320,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Ruta completa',
'tours.stages.totalPartial': 'conocido para {known} de {of} etapas',
'tours.stages.openInPlanner': 'Abrir esta etapa en el planificador',
'tours.collection.save': 'Guardar en una colección',
'tours.collection.saveHint': 'Guarda una copia independiente de esta ruta en una de tus colecciones',
'tours.collection.saveFirst': 'Guarda primero la ruta para conservarla en una colección',
'tours.collection.saveSub': 'Una copia independiente. Los cambios posteriores en esta ruta no le llegan.',
'tours.collection.list': 'Colección',
'tours.collection.readOnly': 'Solo puedes leer esta lista',
'tours.collection.noLists': 'Todavía no tienes ninguna colección.',
'tours.collection.openCollections': 'Abrir colecciones',
'tours.collection.nameThere': 'Nombre en la colección',
'tours.collection.keep': 'Guardar',
'tours.collection.kept': '«{name}» ahora está guardada en «{list}».',
'tours.collection.keepFailed': 'No se pudo guardar la ruta.',
'tours.collection.offline': 'Las colecciones necesitan conexión.',
'tours.collection.listsFailed': 'No se pudieron cargar las colecciones.',
'tours.collection.toursFailed': 'No se pudieron cargar las rutas de esta lista.',
'tours.collection.points': '{count} puntos de ruta',
'tours.collection.points.one': '{count} punto de ruta',
'tours.collection.savedFrom': 'Guardada desde',
'tours.collection.source': 'Guardada desde «{tour}» en «{trip}»',
'tours.collection.keptBy': 'Guardada por {name}',
'tours.collection.add': 'Añadir a un viaje',
'tours.collection.addHint': 'Crea una ruta nueva y propia en el viaje que elijas',
'tours.collection.remove': 'Quitar de la colección',
'tours.collection.removeTitle': '¿Quitar esta ruta de la colección?',
'tours.collection.removeBody': 'Las rutas ya creadas a partir de ella en tus viajes se quedan como están.',
'tours.collection.removed': 'La ruta se quitó de la colección.',
'tours.collection.removeFailed': 'No se pudo quitar la ruta.',
'tours.collection.takeEyebrow': 'Desde la colección',
'tours.collection.takeSub':
'Pasa a ser una ruta propia del viaje que elijas, sin días. La colección se queda como está.',
'tours.collection.intoTrip': 'Al viaje',
'tours.collection.noPermission': 'No puedes añadir rutas a este viaje',
'tours.collection.noTrips': 'Todavía no tienes ningún viaje al que añadirla.',
'tours.collection.withPoints': 'Con {count} puntos de ruta',
'tours.collection.withPoints.one': 'Con {count} punto de ruta',
'tours.collection.withPointsHint': 'Inicio, paradas y final vienen en su orden.',
'tours.collection.lineOnly': 'Solo la línea',
'tours.collection.lineOnlyHint': 'La línea queda como se guardó. No se añaden puntos de ruta.',
'tours.collection.needsPoints': 'Esta ruta aún no tiene línea, así que sus puntos de ruta tienen que venir.',
'tours.collection.nameInTrip': 'Nombre en el viaje',
'tours.collection.taken': '«{name}» ahora es una ruta de «{trip}».',
'tours.collection.takeFailed': 'No se pudo añadir la ruta al viaje.',
'tours.collection.openTrip': 'Abrir viaje',
};
export default tours;
+42
View File
@@ -1304,5 +1304,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Kogu matk',
'tours.stages.totalPartial': 'teada {known} etapi kohta {of}-st',
'tours.stages.openInPlanner': 'Ava see etapp planeerijas',
'tours.collection.save': 'Hoia kogumikus',
'tours.collection.saveHint': 'Hoiab selle matka iseseisvat koopiat ühes sinu kogumikus',
'tours.collection.saveFirst': 'Salvesta matk esmalt, et seda kogumikus hoida',
'tours.collection.saveSub': 'Iseseisev koopia. Hilisemad muudatused matkas seda ei mõjuta.',
'tours.collection.list': 'Kogumik',
'tours.collection.readOnly': 'Seda loendit saad ainult lugeda',
'tours.collection.noLists': 'Sul pole veel ühtegi kogumikku.',
'tours.collection.openCollections': 'Ava kogumikud',
'tours.collection.nameThere': 'Nimi kogumikus',
'tours.collection.keep': 'Hoia',
'tours.collection.kept': '„{name}“ on nüüd kogumikus „{list}“.',
'tours.collection.keepFailed': 'Matka ei õnnestunud hoida.',
'tours.collection.offline': 'Kogumikud vajavad ühendust.',
'tours.collection.listsFailed': 'Kogumikke ei õnnestunud laadida.',
'tours.collection.toursFailed': 'Selle loendi matku ei õnnestunud laadida.',
'tours.collection.points': '{count} teekonnapunkti',
'tours.collection.points.one': '{count} teekonnapunkt',
'tours.collection.savedFrom': 'Hoitud kohast',
'tours.collection.source': 'Hoitud matkast „{tour}“ reisil „{trip}“',
'tours.collection.keptBy': 'Hoidis {name}',
'tours.collection.add': 'Lisa reisile',
'tours.collection.addHint': 'Loob valitud reisile oma uue matka',
'tours.collection.remove': 'Eemalda kogumikust',
'tours.collection.removeTitle': 'Kas eemaldada see matk kogumikust?',
'tours.collection.removeBody': 'Matkad, mis sinu reisidel juba sellest tehti, jäävad samaks.',
'tours.collection.removed': 'Matk eemaldati kogumikust.',
'tours.collection.removeFailed': 'Matka ei õnnestunud eemaldada.',
'tours.collection.takeEyebrow': 'Kogumikust',
'tours.collection.takeSub': 'Saab valitud reisil omaette matkaks, ilma päevadeta. Kogumik jääb samaks.',
'tours.collection.intoTrip': 'Reisile',
'tours.collection.noPermission': 'Sa ei tohi sellele reisile matku lisada',
'tours.collection.noTrips': 'Sul pole veel reisi, kuhu seda lisada.',
'tours.collection.withPoints': '{count} teekonnapunktiga',
'tours.collection.withPoints.one': '{count} teekonnapunktiga',
'tours.collection.withPointsHint': 'Algus, peatused ja lõpp tulevad kaasa oma järjekorras.',
'tours.collection.lineOnly': 'Ainult joon',
'tours.collection.lineOnlyHint': 'Joon jääb nii, nagu see salvestati. Teekonnapunkte ei lisata.',
'tours.collection.needsPoints': 'Sellel matkal pole veel joont, seega peavad selle teekonnapunktid kaasa tulema.',
'tours.collection.nameInTrip': 'Nimi reisil',
'tours.collection.taken': '„{name}“ on nüüd reisi „{trip}“ matk.',
'tours.collection.takeFailed': 'Matka ei õnnestunud reisile lisada.',
'tours.collection.openTrip': 'Ava reis',
};
export default tours;
+44
View File
@@ -1347,5 +1347,49 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Itinéraire complet',
'tours.stages.totalPartial': 'connu pour {known} étapes sur {of}',
'tours.stages.openInPlanner': 'Ouvrir cette étape dans le planificateur',
'tours.collection.save': 'Garder dans une collection',
'tours.collection.saveHint': 'Garde une copie indépendante de cet itinéraire dans une de vos collections',
'tours.collection.saveFirst': "Enregistrez d'abord l'itinéraire pour le garder dans une collection",
'tours.collection.saveSub':
"Une copie indépendante. Les modifications ultérieures de cet itinéraire ne l'atteignent pas.",
'tours.collection.list': 'Collection', // same-as-en
'tours.collection.readOnly': 'Vous pouvez seulement lire cette liste',
'tours.collection.noLists': "Vous n'avez encore aucune collection.",
'tours.collection.openCollections': 'Ouvrir les collections',
'tours.collection.nameThere': 'Nom dans la collection',
'tours.collection.keep': 'Garder',
'tours.collection.kept': '« {name} » est maintenant gardé dans « {list} ».',
'tours.collection.keepFailed': "L'itinéraire n'a pas pu être gardé.",
'tours.collection.offline': "Les collections ont besoin d'une connexion.",
'tours.collection.listsFailed': "Les collections n'ont pas pu être chargées.",
'tours.collection.toursFailed': "Les itinéraires de cette liste n'ont pas pu être chargés.",
'tours.collection.points': '{count} points de passage',
'tours.collection.points.one': '{count} point de passage',
'tours.collection.savedFrom': 'Gardé depuis',
'tours.collection.source': 'Gardé depuis « {tour} » dans « {trip} »',
'tours.collection.keptBy': 'Gardé par {name}',
'tours.collection.add': 'Ajouter à un voyage',
'tours.collection.addHint': 'Crée un nouvel itinéraire à part entière dans le voyage de votre choix',
'tours.collection.remove': 'Retirer de la collection',
'tours.collection.removeTitle': 'Retirer cet itinéraire de la collection ?',
'tours.collection.removeBody': 'Les itinéraires déjà créés à partir de celui-ci dans vos voyages restent tels quels.',
'tours.collection.removed': "L'itinéraire a été retiré de la collection.",
'tours.collection.removeFailed': "L'itinéraire n'a pas pu être retiré.",
'tours.collection.takeEyebrow': 'Depuis la collection',
'tours.collection.takeSub':
'Devient un itinéraire à part entière dans le voyage choisi, sans jours. La collection reste telle quelle.',
'tours.collection.intoTrip': 'Dans le voyage',
'tours.collection.noPermission': "Vous ne pouvez pas ajouter d'itinéraires à ce voyage",
'tours.collection.noTrips': "Vous n'avez encore aucun voyage où l'ajouter.",
'tours.collection.withPoints': 'Avec {count} points de passage',
'tours.collection.withPoints.one': 'Avec {count} point de passage',
'tours.collection.withPointsHint': 'Départ, étapes et arrivée suivent dans leur ordre.',
'tours.collection.lineOnly': 'Tracé seul',
'tours.collection.lineOnlyHint': "Le tracé reste tel qu'il a été enregistré. Aucun point de passage n'est ajouté.",
'tours.collection.needsPoints': "Cet itinéraire n'a pas encore de tracé, ses points de passage doivent donc suivre.",
'tours.collection.nameInTrip': 'Nom dans le voyage',
'tours.collection.taken': '« {name} » est maintenant un itinéraire de « {trip} ».',
'tours.collection.takeFailed': "L'itinéraire n'a pas pu être ajouté au voyage.",
'tours.collection.openTrip': 'Ouvrir le voyage',
};
export default tours;
+43
View File
@@ -1349,5 +1349,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Όλη η διαδρομή',
'tours.stages.totalPartial': 'γνωστό για {known} από {of} σκέλη',
'tours.stages.openInPlanner': 'Άνοιγμα αυτού του σκέλους στον σχεδιαστή',
'tours.collection.save': 'Φύλαξη σε συλλογή',
'tours.collection.saveHint': 'Φυλάει ένα ανεξάρτητο αντίγραφο αυτής της διαδρομής σε μία από τις συλλογές σου',
'tours.collection.saveFirst': 'Αποθήκευσε πρώτα τη διαδρομή για να τη φυλάξεις σε συλλογή',
'tours.collection.saveSub': 'Ένα ανεξάρτητο αντίγραφο. Μεταγενέστερες αλλαγές στη διαδρομή δεν το επηρεάζουν.',
'tours.collection.list': 'Συλλογή',
'tours.collection.readOnly': 'Μπορείς μόνο να διαβάσεις αυτή τη λίστα',
'tours.collection.noLists': 'Δεν έχεις ακόμη καμία συλλογή.',
'tours.collection.openCollections': 'Άνοιγμα συλλογών',
'tours.collection.nameThere': 'Όνομα στη συλλογή',
'tours.collection.keep': 'Φύλαξη',
'tours.collection.kept': 'Το «{name}» φυλάσσεται τώρα στο «{list}».',
'tours.collection.keepFailed': 'Δεν ήταν δυνατή η φύλαξη της διαδρομής.',
'tours.collection.offline': 'Οι συλλογές χρειάζονται σύνδεση.',
'tours.collection.listsFailed': 'Δεν ήταν δυνατή η φόρτωση των συλλογών.',
'tours.collection.toursFailed': 'Δεν ήταν δυνατή η φόρτωση των διαδρομών αυτής της λίστας.',
'tours.collection.points': '{count} σημεία διαδρομής',
'tours.collection.points.one': '{count} σημείο διαδρομής',
'tours.collection.savedFrom': 'Φυλάχτηκε από',
'tours.collection.source': 'Φυλάχτηκε από «{tour}» στο «{trip}»',
'tours.collection.keptBy': 'Φυλάχτηκε από {name}',
'tours.collection.add': 'Προσθήκη σε ταξίδι',
'tours.collection.addHint': 'Δημιουργεί μια νέα, δική της διαδρομή σε ταξίδι της επιλογής σου',
'tours.collection.remove': 'Αφαίρεση από τη συλλογή',
'tours.collection.removeTitle': 'Αφαίρεση αυτής της διαδρομής από τη συλλογή;',
'tours.collection.removeBody': 'Οι διαδρομές που έχουν ήδη φτιαχτεί από αυτή στα ταξίδια σου μένουν ως έχουν.',
'tours.collection.removed': 'Η διαδρομή αφαιρέθηκε από τη συλλογή.',
'tours.collection.removeFailed': 'Δεν ήταν δυνατή η αφαίρεση της διαδρομής.',
'tours.collection.takeEyebrow': 'Από τη συλλογή',
'tours.collection.takeSub':
'Γίνεται δική της διαδρομή στο ταξίδι που επιλέγεις, χωρίς ημέρες. Η συλλογή μένει ως έχει.',
'tours.collection.intoTrip': 'Στο ταξίδι',
'tours.collection.noPermission': 'Δεν μπορείς να προσθέσεις διαδρομές σε αυτό το ταξίδι',
'tours.collection.noTrips': 'Δεν έχεις ακόμη ταξίδι για να την προσθέσεις.',
'tours.collection.withPoints': 'Με {count} σημεία διαδρομής',
'tours.collection.withPoints.one': 'Με {count} σημείο διαδρομής',
'tours.collection.withPointsHint': 'Αφετηρία, στάσεις και τέλος έρχονται με τη σειρά τους.',
'tours.collection.lineOnly': 'Μόνο η γραμμή',
'tours.collection.lineOnlyHint': 'Η γραμμή μένει όπως αποθηκεύτηκε. Δεν προστίθενται σημεία διαδρομής.',
'tours.collection.needsPoints': 'Αυτή η διαδρομή δεν έχει ακόμη γραμμή, οπότε τα σημεία της πρέπει να έρθουν μαζί.',
'tours.collection.nameInTrip': 'Όνομα στο ταξίδι',
'tours.collection.taken': 'Το «{name}» είναι τώρα διαδρομή του «{trip}».',
'tours.collection.takeFailed': 'Δεν ήταν δυνατή η προσθήκη της διαδρομής στο ταξίδι.',
'tours.collection.openTrip': 'Άνοιγμα ταξιδιού',
};
export default tours;
+42
View File
@@ -1326,5 +1326,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Teljes túra',
'tours.stages.totalPartial': 'ismert {known} szakasznál a {of} közül',
'tours.stages.openInPlanner': 'A szakasz megnyitása a tervezőben',
'tours.collection.save': 'Megőrzés gyűjteményben',
'tours.collection.saveHint': 'A túra önálló másolatát az egyik gyűjteményedbe teszi',
'tours.collection.saveFirst': 'Előbb mentsd a túrát, hogy gyűjteménybe tehesd',
'tours.collection.saveSub': 'Önálló másolat. A túra későbbi módosításai nem érik el.',
'tours.collection.list': 'Gyűjtemény',
'tours.collection.readOnly': 'Ezt a listát csak olvasni tudod',
'tours.collection.noLists': 'Még nincs gyűjteményed.',
'tours.collection.openCollections': 'Gyűjtemények megnyitása',
'tours.collection.nameThere': 'Név a gyűjteményben',
'tours.collection.keep': 'Megőrzés',
'tours.collection.kept': 'A(z) „{name}” most a(z) „{list}” gyűjteményben van.',
'tours.collection.keepFailed': 'A túrát nem sikerült megőrizni.',
'tours.collection.offline': 'A gyűjteményekhez kapcsolat kell.',
'tours.collection.listsFailed': 'A gyűjteményeket nem sikerült betölteni.',
'tours.collection.toursFailed': 'A lista túráit nem sikerült betölteni.',
'tours.collection.points': '{count} útpont',
'tours.collection.points.one': '{count} útpont',
'tours.collection.savedFrom': 'Megőrizve innen',
'tours.collection.source': 'Megőrizve innen: „{tour}”, „{trip}”',
'tours.collection.keptBy': 'Megőrizte: {name}',
'tours.collection.add': 'Hozzáadás egy úthoz',
'tours.collection.addHint': 'Önálló új túrát hoz létre egy általad választott útban',
'tours.collection.remove': 'Eltávolítás a gyűjteményből',
'tours.collection.removeTitle': 'Eltávolítod ezt a túrát a gyűjteményből?',
'tours.collection.removeBody': 'Az útjaidban belőle már készült túrák változatlanok maradnak.',
'tours.collection.removed': 'A túra kikerült a gyűjteményből.',
'tours.collection.removeFailed': 'A túrát nem sikerült eltávolítani.',
'tours.collection.takeEyebrow': 'A gyűjteményből',
'tours.collection.takeSub': 'Önálló túra lesz a választott útban, napok nélkül. A gyűjtemény változatlan marad.',
'tours.collection.intoTrip': 'Az útba',
'tours.collection.noPermission': 'Ehhez az úthoz nem adhatsz túrát',
'tours.collection.noTrips': 'Még nincs utad, amelyhez hozzáadhatnád.',
'tours.collection.withPoints': '{count} útponttal',
'tours.collection.withPoints.one': '{count} útponttal',
'tours.collection.withPointsHint': 'A kezdőpont, a megállók és a végpont a sorrendjükben jönnek.',
'tours.collection.lineOnly': 'Csak a vonal',
'tours.collection.lineOnlyHint': 'A vonal a mentett formájában marad. Útpontok nem kerülnek hozzá.',
'tours.collection.needsPoints': 'Ennek a túrának még nincs vonala, ezért az útpontjainak is jönniük kell.',
'tours.collection.nameInTrip': 'Név az útban',
'tours.collection.taken': 'A(z) „{name}” most a(z) „{trip}” túrája.',
'tours.collection.takeFailed': 'A túrát nem sikerült hozzáadni az úthoz.',
'tours.collection.openTrip': 'Út megnyitása',
};
export default tours;
+41
View File
@@ -1300,5 +1300,46 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Seluruh tur',
'tours.stages.totalPartial': 'diketahui untuk {known} dari {of} etape',
'tours.stages.openInPlanner': 'Buka etape ini di perencana',
'tours.collection.save': 'Simpan di koleksi',
'tours.collection.saveHint': 'Menyimpan salinan mandiri tur ini di salah satu koleksi Anda',
'tours.collection.saveFirst': 'Simpan tur terlebih dahulu untuk menaruhnya di koleksi',
'tours.collection.saveSub': 'Salinan mandiri. Perubahan berikutnya pada tur ini tidak memengaruhinya.',
'tours.collection.list': 'Koleksi',
'tours.collection.readOnly': 'Anda hanya dapat membaca daftar ini',
'tours.collection.noLists': 'Anda belum punya koleksi.',
'tours.collection.openCollections': 'Buka koleksi',
'tours.collection.nameThere': 'Nama di koleksi',
'tours.collection.keep': 'Simpan',
'tours.collection.kept': '“{name}” kini tersimpan di “{list}”.',
'tours.collection.keepFailed': 'Tur tidak dapat disimpan.',
'tours.collection.offline': 'Koleksi memerlukan koneksi.',
'tours.collection.listsFailed': 'Koleksi tidak dapat dimuat.',
'tours.collection.toursFailed': 'Tur di daftar ini tidak dapat dimuat.',
'tours.collection.points': '{count} titik rute',
'tours.collection.savedFrom': 'Disimpan dari',
'tours.collection.source': 'Disimpan dari “{tour}” di “{trip}”',
'tours.collection.keptBy': 'Disimpan oleh {name}',
'tours.collection.add': 'Tambahkan ke perjalanan',
'tours.collection.addHint': 'Membuat tur baru tersendiri di perjalanan pilihan Anda',
'tours.collection.remove': 'Hapus dari koleksi',
'tours.collection.removeTitle': 'Hapus tur ini dari koleksi?',
'tours.collection.removeBody': 'Tur yang sudah dibuat darinya di perjalanan Anda tetap seperti semula.',
'tours.collection.removed': 'Tur dihapus dari koleksi.',
'tours.collection.removeFailed': 'Tur tidak dapat dihapus.',
'tours.collection.takeEyebrow': 'Dari koleksi',
'tours.collection.takeSub':
'Menjadi tur tersendiri di perjalanan pilihan Anda, tanpa hari. Koleksi tetap seperti semula.',
'tours.collection.intoTrip': 'Ke perjalanan',
'tours.collection.noPermission': 'Anda tidak boleh menambahkan tur ke perjalanan ini',
'tours.collection.noTrips': 'Anda belum punya perjalanan untuk menambahkannya.',
'tours.collection.withPoints': 'Dengan {count} titik rute',
'tours.collection.withPointsHint': 'Awal, perhentian, dan akhir ikut sesuai urutannya.',
'tours.collection.lineOnly': 'Hanya garis',
'tours.collection.lineOnlyHint': 'Garis tetap seperti saat disimpan. Tidak ada titik rute yang ditambahkan.',
'tours.collection.needsPoints': 'Tur ini belum punya garis, jadi titik rutenya harus ikut.',
'tours.collection.nameInTrip': 'Nama di perjalanan',
'tours.collection.taken': '“{name}” kini menjadi tur di “{trip}”.',
'tours.collection.takeFailed': 'Tur tidak dapat ditambahkan ke perjalanan.',
'tours.collection.openTrip': 'Buka perjalanan',
};
export default tours;
+43
View File
@@ -1334,5 +1334,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Tour completo',
'tours.stages.totalPartial': 'noto per {known} tappe su {of}',
'tours.stages.openInPlanner': 'Apri questa tappa nel pianificatore',
'tours.collection.save': 'Tieni in una raccolta',
'tours.collection.saveHint': 'Tiene una copia indipendente di questo percorso in una delle tue raccolte',
'tours.collection.saveFirst': 'Salva prima il percorso per tenerlo in una raccolta',
'tours.collection.saveSub': 'Una copia indipendente. Le modifiche successive a questo percorso non la raggiungono.',
'tours.collection.list': 'Raccolta',
'tours.collection.readOnly': 'Puoi solo leggere questa lista',
'tours.collection.noLists': 'Non hai ancora nessuna raccolta.',
'tours.collection.openCollections': 'Apri le raccolte',
'tours.collection.nameThere': 'Nome nella raccolta',
'tours.collection.keep': 'Tieni',
'tours.collection.kept': '“{name}” ora è tenuto in “{list}”.',
'tours.collection.keepFailed': 'Non è stato possibile tenere il percorso.',
'tours.collection.offline': 'Le raccolte hanno bisogno di una connessione.',
'tours.collection.listsFailed': 'Non è stato possibile caricare le raccolte.',
'tours.collection.toursFailed': 'Non è stato possibile caricare i percorsi di questa lista.',
'tours.collection.points': '{count} punti di passaggio',
'tours.collection.points.one': '{count} punto di passaggio',
'tours.collection.savedFrom': 'Tenuto da',
'tours.collection.source': 'Tenuto da “{tour}” in “{trip}”',
'tours.collection.keptBy': 'Tenuto da {name}',
'tours.collection.add': 'Aggiungi a un viaggio',
'tours.collection.addHint': 'Crea un nuovo percorso a sé nel viaggio che scegli',
'tours.collection.remove': 'Togli dalla raccolta',
'tours.collection.removeTitle': 'Togliere questo percorso dalla raccolta?',
'tours.collection.removeBody': 'I percorsi già creati da questo nei tuoi viaggi restano come sono.',
'tours.collection.removed': 'Il percorso è stato tolto dalla raccolta.',
'tours.collection.removeFailed': 'Non è stato possibile togliere il percorso.',
'tours.collection.takeEyebrow': 'Dalla raccolta',
'tours.collection.takeSub': 'Diventa un percorso a sé nel viaggio scelto, senza giorni. La raccolta resta com’è.',
'tours.collection.intoTrip': 'Nel viaggio',
'tours.collection.noPermission': 'Non puoi aggiungere percorsi a questo viaggio',
'tours.collection.noTrips': 'Non hai ancora nessun viaggio a cui aggiungerlo.',
'tours.collection.withPoints': 'Con {count} punti di passaggio',
'tours.collection.withPoints.one': 'Con {count} punto di passaggio',
'tours.collection.withPointsHint': 'Partenza, tappe e arrivo seguono nel loro ordine.',
'tours.collection.lineOnly': 'Solo la linea',
'tours.collection.lineOnlyHint': 'La linea resta come è stata salvata. Nessun punto di passaggio viene aggiunto.',
'tours.collection.needsPoints':
'Questo percorso non ha ancora una linea, quindi i suoi punti di passaggio devono seguire.',
'tours.collection.nameInTrip': 'Nome nel viaggio',
'tours.collection.taken': '“{name}” ora è un percorso di “{trip}”.',
'tours.collection.takeFailed': 'Non è stato possibile aggiungere il percorso al viaggio.',
'tours.collection.openTrip': 'Apri il viaggio',
};
export default tours;
+40
View File
@@ -1262,5 +1262,45 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'ツアー全体',
'tours.stages.totalPartial': '{of} 件中 {known} 件のステージで判明',
'tours.stages.openInPlanner': 'このステージをプランナーで開く',
'tours.collection.save': 'コレクションに保存',
'tours.collection.saveHint': 'このツアーの独立したコピーをコレクションのひとつに保存します',
'tours.collection.saveFirst': 'コレクションに保存する前にツアーを保存してください',
'tours.collection.saveSub': '独立したコピーです。このツアーを後で変更しても反映されません。',
'tours.collection.list': 'コレクション',
'tours.collection.readOnly': 'このリストは閲覧のみできます',
'tours.collection.noLists': 'まだコレクションがありません。',
'tours.collection.openCollections': 'コレクションを開く',
'tours.collection.nameThere': 'コレクションでの名前',
'tours.collection.keep': '保存',
'tours.collection.kept': '「{name}」を「{list}」に保存しました。',
'tours.collection.keepFailed': 'ツアーを保存できませんでした。',
'tours.collection.offline': 'コレクションには接続が必要です。',
'tours.collection.listsFailed': 'コレクションを読み込めませんでした。',
'tours.collection.toursFailed': 'このリストのツアーを読み込めませんでした。',
'tours.collection.points': '経由地 {count} 件',
'tours.collection.savedFrom': '保存元',
'tours.collection.source': '「{trip}」の「{tour}」から保存',
'tours.collection.keptBy': '{name} が保存',
'tours.collection.add': '旅に追加',
'tours.collection.addHint': '選んだ旅に独立した新しいツアーを作成します',
'tours.collection.remove': 'コレクションから削除',
'tours.collection.removeTitle': 'このツアーをコレクションから削除しますか?',
'tours.collection.removeBody': 'これから旅に作成済みのツアーはそのまま残ります。',
'tours.collection.removed': 'ツアーをコレクションから削除しました。',
'tours.collection.removeFailed': 'ツアーを削除できませんでした。',
'tours.collection.takeEyebrow': 'コレクションから',
'tours.collection.takeSub': '選んだ旅の独立したツアーになります(日程なし)。コレクションは変わりません。',
'tours.collection.intoTrip': '追加先の旅',
'tours.collection.noPermission': 'この旅にはツアーを追加できません',
'tours.collection.noTrips': '追加できる旅がまだありません。',
'tours.collection.withPoints': '経由地 {count} 件と一緒に',
'tours.collection.withPointsHint': '出発点、途中の地点、終点が順番どおりに移ります。',
'tours.collection.lineOnly': '線のみ',
'tours.collection.lineOnlyHint': '線は保存時のままです。経由地は追加されません。',
'tours.collection.needsPoints': 'このツアーにはまだ線がないため、経由地も一緒に移す必要があります。',
'tours.collection.nameInTrip': '旅での名前',
'tours.collection.taken': '「{name}」は「{trip}」のツアーになりました。',
'tours.collection.takeFailed': 'ツアーを旅に追加できませんでした。',
'tours.collection.openTrip': '旅を開く',
};
export default tours;
+40
View File
@@ -1260,5 +1260,45 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': '전체 투어',
'tours.stages.totalPartial': '{of}개 구간 중 {known}개에서 알려짐',
'tours.stages.openInPlanner': '이 구간을 플래너에서 열기',
'tours.collection.save': '컬렉션에 보관',
'tours.collection.saveHint': '이 투어의 독립된 사본을 내 컬렉션 중 하나에 보관합니다',
'tours.collection.saveFirst': '컬렉션에 보관하려면 먼저 투어를 저장하세요',
'tours.collection.saveSub': '독립된 사본입니다. 이후 이 투어를 바꿔도 반영되지 않습니다.',
'tours.collection.list': '컬렉션',
'tours.collection.readOnly': '이 목록은 읽기만 할 수 있습니다',
'tours.collection.noLists': '아직 컬렉션이 없습니다.',
'tours.collection.openCollections': '컬렉션 열기',
'tours.collection.nameThere': '컬렉션에서의 이름',
'tours.collection.keep': '보관',
'tours.collection.kept': '“{name}”을(를) “{list}”에 보관했습니다.',
'tours.collection.keepFailed': '투어를 보관하지 못했습니다.',
'tours.collection.offline': '컬렉션에는 연결이 필요합니다.',
'tours.collection.listsFailed': '컬렉션을 불러오지 못했습니다.',
'tours.collection.toursFailed': '이 목록의 투어를 불러오지 못했습니다.',
'tours.collection.points': '경유지 {count}개',
'tours.collection.savedFrom': '보관 출처',
'tours.collection.source': '“{trip}”의 “{tour}”에서 보관',
'tours.collection.keptBy': '{name} 님이 보관',
'tours.collection.add': '여행에 추가',
'tours.collection.addHint': '선택한 여행에 독립된 새 투어를 만듭니다',
'tours.collection.remove': '컬렉션에서 제거',
'tours.collection.removeTitle': '이 투어를 컬렉션에서 제거할까요?',
'tours.collection.removeBody': '이미 여행에서 이것으로 만든 투어는 그대로 남습니다.',
'tours.collection.removed': '투어를 컬렉션에서 제거했습니다.',
'tours.collection.removeFailed': '투어를 제거하지 못했습니다.',
'tours.collection.takeEyebrow': '컬렉션에서',
'tours.collection.takeSub': '선택한 여행의 독립된 투어가 되며 일정은 없습니다. 컬렉션은 그대로입니다.',
'tours.collection.intoTrip': '추가할 여행',
'tours.collection.noPermission': '이 여행에는 투어를 추가할 수 없습니다',
'tours.collection.noTrips': '아직 추가할 여행이 없습니다.',
'tours.collection.withPoints': '경유지 {count}개와 함께',
'tours.collection.withPointsHint': '출발지, 중간 지점, 도착지가 순서대로 함께 옵니다.',
'tours.collection.lineOnly': '선만',
'tours.collection.lineOnlyHint': '선은 저장된 그대로입니다. 경유지는 추가되지 않습니다.',
'tours.collection.needsPoints': '이 투어는 아직 선이 없어 경유지도 함께 가져와야 합니다.',
'tours.collection.nameInTrip': '여행에서의 이름',
'tours.collection.taken': '“{name}”이(가) 이제 “{trip}”의 투어입니다.',
'tours.collection.takeFailed': '투어를 여행에 추가하지 못했습니다.',
'tours.collection.openTrip': '여행 열기',
};
export default tours;
+43
View File
@@ -1330,5 +1330,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Hele tocht',
'tours.stages.totalPartial': 'bekend voor {known} van {of} etappes',
'tours.stages.openInPlanner': 'Deze etappe openen in de planner',
'tours.collection.save': 'In een collectie bewaren',
'tours.collection.saveHint': 'Bewaart een zelfstandige kopie van deze tocht in een van je collecties',
'tours.collection.saveFirst': 'Sla de tocht eerst op om hem in een collectie te bewaren',
'tours.collection.saveSub': 'Een zelfstandige kopie. Latere wijzigingen aan deze tocht bereiken haar niet.',
'tours.collection.list': 'Collectie',
'tours.collection.readOnly': 'Je kunt deze lijst alleen lezen',
'tours.collection.noLists': 'Je hebt nog geen collectie.',
'tours.collection.openCollections': 'Collecties openen',
'tours.collection.nameThere': 'Naam in de collectie',
'tours.collection.keep': 'Bewaren',
'tours.collection.kept': '‘{name}’ staat nu in ‘{list}’.',
'tours.collection.keepFailed': 'De tocht kon niet worden bewaard.',
'tours.collection.offline': 'Collecties hebben een verbinding nodig.',
'tours.collection.listsFailed': 'De collecties konden niet worden geladen.',
'tours.collection.toursFailed': 'De tochten van deze lijst konden niet worden geladen.',
'tours.collection.points': '{count} routepunten',
'tours.collection.points.one': '{count} routepunt',
'tours.collection.savedFrom': 'Bewaard uit',
'tours.collection.source': 'Bewaard uit ‘{tour}’ in ‘{trip}’',
'tours.collection.keptBy': 'Bewaard door {name}',
'tours.collection.add': 'Aan een reis toevoegen',
'tours.collection.addHint': 'Maakt een eigen nieuwe tocht in een reis naar keuze',
'tours.collection.remove': 'Uit de collectie halen',
'tours.collection.removeTitle': 'Deze tocht uit de collectie halen?',
'tours.collection.removeBody': 'Tochten die er al in je reizen van gemaakt zijn, blijven zoals ze zijn.',
'tours.collection.removed': 'De tocht is uit de collectie gehaald.',
'tours.collection.removeFailed': 'De tocht kon niet worden verwijderd.',
'tours.collection.takeEyebrow': 'Uit de collectie',
'tours.collection.takeSub':
'Wordt een eigen tocht in de gekozen reis, zonder dagen. De collectie blijft zoals ze is.',
'tours.collection.intoTrip': 'In de reis',
'tours.collection.noPermission': 'Je mag aan deze reis geen tochten toevoegen',
'tours.collection.noTrips': 'Je hebt nog geen reis om hem aan toe te voegen.',
'tours.collection.withPoints': 'Met {count} routepunten',
'tours.collection.withPoints.one': 'Met {count} routepunt',
'tours.collection.withPointsHint': 'Start, tussenstops en einde komen mee in hun volgorde.',
'tours.collection.lineOnly': 'Alleen de lijn',
'tours.collection.lineOnlyHint': 'De lijn blijft zoals ze is opgeslagen. Er komen geen routepunten bij.',
'tours.collection.needsPoints': 'Deze tocht heeft nog geen lijn, dus zijn routepunten moeten meekomen.',
'tours.collection.nameInTrip': 'Naam in de reis',
'tours.collection.taken': '‘{name}’ is nu een tocht van ‘{trip}’.',
'tours.collection.takeFailed': 'De tocht kon niet aan de reis worden toegevoegd.',
'tours.collection.openTrip': 'Reis openen',
};
export default tours;
+46
View File
@@ -1363,5 +1363,51 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Cała trasa',
'tours.stages.totalPartial': 'znane dla {known} z {of} etapów',
'tours.stages.openInPlanner': 'Otwórz ten etap w planerze',
'tours.collection.save': 'Zachowaj w kolekcji',
'tours.collection.saveHint': 'Zachowuje niezależną kopię tej trasy w jednej z twoich kolekcji',
'tours.collection.saveFirst': 'Najpierw zapisz trasę, aby zachować ją w kolekcji',
'tours.collection.saveSub': 'Niezależna kopia. Późniejsze zmiany tej trasy do niej nie trafiają.',
'tours.collection.list': 'Kolekcja',
'tours.collection.readOnly': 'Tę listę możesz tylko czytać',
'tours.collection.noLists': 'Nie masz jeszcze żadnej kolekcji.',
'tours.collection.openCollections': 'Otwórz kolekcje',
'tours.collection.nameThere': 'Nazwa w kolekcji',
'tours.collection.keep': 'Zachowaj',
'tours.collection.kept': '„{name}” jest teraz w „{list}”.',
'tours.collection.keepFailed': 'Nie udało się zachować trasy.',
'tours.collection.offline': 'Kolekcje wymagają połączenia.',
'tours.collection.listsFailed': 'Nie udało się wczytać kolekcji.',
'tours.collection.toursFailed': 'Nie udało się wczytać tras tej listy.',
'tours.collection.points': '{count} punktu trasy',
'tours.collection.points.one': '{count} punkt trasy',
'tours.collection.points.few': '{count} punkty trasy',
'tours.collection.points.many': '{count} punktów trasy',
'tours.collection.savedFrom': 'Zachowano z',
'tours.collection.source': 'Zachowano z „{tour}” w „{trip}”',
'tours.collection.keptBy': 'Zachował(a): {name}',
'tours.collection.add': 'Dodaj do podróży',
'tours.collection.addHint': 'Tworzy nową, własną trasę w wybranej podróży',
'tours.collection.remove': 'Usuń z kolekcji',
'tours.collection.removeTitle': 'Usunąć tę trasę z kolekcji?',
'tours.collection.removeBody': 'Trasy już z niej utworzone w twoich podróżach pozostają bez zmian.',
'tours.collection.removed': 'Trasę usunięto z kolekcji.',
'tours.collection.removeFailed': 'Nie udało się usunąć trasy.',
'tours.collection.takeEyebrow': 'Z kolekcji',
'tours.collection.takeSub': 'Staje się własną trasą wybranej podróży, bez dni. Kolekcja pozostaje bez zmian.',
'tours.collection.intoTrip': 'Do podróży',
'tours.collection.noPermission': 'Nie możesz dodawać tras do tej podróży',
'tours.collection.noTrips': 'Nie masz jeszcze podróży, do której można ją dodać.',
'tours.collection.withPoints': 'Z {count} punktu trasy',
'tours.collection.withPoints.one': 'Z {count} punktem trasy',
'tours.collection.withPoints.few': 'Z {count} punktami trasy',
'tours.collection.withPoints.many': 'Z {count} punktami trasy',
'tours.collection.withPointsHint': 'Start, przystanki i meta przechodzą w swojej kolejności.',
'tours.collection.lineOnly': 'Tylko linia',
'tours.collection.lineOnlyHint': 'Linia zostaje taka, jak ją zapisano. Nie dochodzą żadne punkty trasy.',
'tours.collection.needsPoints': 'Ta trasa nie ma jeszcze linii, więc jej punkty trasy muszą przejść razem z nią.',
'tours.collection.nameInTrip': 'Nazwa w podróży',
'tours.collection.taken': '„{name}” jest teraz trasą podróży „{trip}”.',
'tours.collection.takeFailed': 'Nie udało się dodać trasy do podróży.',
'tours.collection.openTrip': 'Otwórz podróż',
};
export default tours;
+46
View File
@@ -1369,5 +1369,51 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Весь маршрут',
'tours.stages.totalPartial': 'известно для {known} из {of} этапов',
'tours.stages.openInPlanner': 'Открыть этот этап в планировщике',
'tours.collection.save': 'Сохранить в коллекции',
'tours.collection.saveHint': 'Сохраняет независимую копию этого маршрута в одной из ваших коллекций',
'tours.collection.saveFirst': 'Сначала сохраните маршрут, чтобы положить его в коллекцию',
'tours.collection.saveSub': 'Независимая копия. Последующие изменения маршрута её не затронут.',
'tours.collection.list': 'Коллекция',
'tours.collection.readOnly': 'Этот список доступен вам только для чтения',
'tours.collection.noLists': 'У вас пока нет ни одной коллекции.',
'tours.collection.openCollections': 'Открыть коллекции',
'tours.collection.nameThere': 'Название в коллекции',
'tours.collection.keep': 'Сохранить',
'tours.collection.kept': '«{name}» теперь хранится в «{list}».',
'tours.collection.keepFailed': 'Не удалось сохранить маршрут.',
'tours.collection.offline': 'Для коллекций нужно подключение.',
'tours.collection.listsFailed': 'Не удалось загрузить коллекции.',
'tours.collection.toursFailed': 'Не удалось загрузить маршруты этого списка.',
'tours.collection.points': 'Точек маршрута: {count}',
'tours.collection.points.one': '{count} точка маршрута',
'tours.collection.points.few': '{count} точки маршрута',
'tours.collection.points.many': '{count} точек маршрута',
'tours.collection.savedFrom': 'Сохранён из',
'tours.collection.source': 'Сохранён из «{tour}» в «{trip}»',
'tours.collection.keptBy': 'Сохранил(а): {name}',
'tours.collection.add': 'Добавить в поездку',
'tours.collection.addHint': 'Создаёт новый собственный маршрут в выбранной поездке',
'tours.collection.remove': 'Убрать из коллекции',
'tours.collection.removeTitle': 'Убрать этот маршрут из коллекции?',
'tours.collection.removeBody': 'Маршруты, уже созданные из него в ваших поездках, останутся как есть.',
'tours.collection.removed': 'Маршрут убран из коллекции.',
'tours.collection.removeFailed': 'Не удалось убрать маршрут.',
'tours.collection.takeEyebrow': 'Из коллекции',
'tours.collection.takeSub': 'Станет собственным маршрутом выбранной поездки, без дней. Коллекция останется как есть.',
'tours.collection.intoTrip': 'В поездку',
'tours.collection.noPermission': 'Вы не можете добавлять маршруты в эту поездку',
'tours.collection.noTrips': 'У вас пока нет поездки, куда его добавить.',
'tours.collection.withPoints': 'Точек маршрута: {count}',
'tours.collection.withPoints.one': 'С {count} точкой маршрута',
'tours.collection.withPoints.few': 'С {count} точками маршрута',
'tours.collection.withPoints.many': 'С {count} точками маршрута',
'tours.collection.withPointsHint': 'Старт, остановки и финиш переходят в своём порядке.',
'tours.collection.lineOnly': 'Только линия',
'tours.collection.lineOnlyHint': 'Линия остаётся такой, как была сохранена. Точки маршрута не добавляются.',
'tours.collection.needsPoints': 'У этого маршрута ещё нет линии, поэтому его точки должны перейти вместе с ним.',
'tours.collection.nameInTrip': 'Название в поездке',
'tours.collection.taken': '«{name}» теперь маршрут поездки «{trip}».',
'tours.collection.takeFailed': 'Не удалось добавить маршрут в поездку.',
'tours.collection.openTrip': 'Открыть поездку',
};
export default tours;
+44
View File
@@ -1317,5 +1317,49 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Celá trasa',
'tours.stages.totalPartial': 'známe pre {known} z {of} etáp',
'tours.stages.openInPlanner': 'Otvoriť túto etapu v plánovači',
'tours.collection.save': 'Uložiť do zbierky',
'tours.collection.saveHint': 'Uloží nezávislú kópiu tejto túry do jednej z tvojich zbierok',
'tours.collection.saveFirst': 'Najprv túru ulož, aby si ju mohol dať do zbierky',
'tours.collection.saveSub': 'Nezávislá kópia. Neskoršie zmeny tejto túry sa jej nedotknú.',
'tours.collection.list': 'Zbierka',
'tours.collection.readOnly': 'Tento zoznam môžeš len čítať',
'tours.collection.noLists': 'Zatiaľ nemáš žiadnu zbierku.',
'tours.collection.openCollections': 'Otvoriť zbierky',
'tours.collection.nameThere': 'Názov v zbierke',
'tours.collection.keep': 'Uložiť',
'tours.collection.kept': '„{name}“ je teraz v zbierke „{list}“.',
'tours.collection.keepFailed': 'Túru sa nepodarilo uložiť.',
'tours.collection.offline': 'Zbierky potrebujú pripojenie.',
'tours.collection.listsFailed': 'Zbierky sa nepodarilo načítať.',
'tours.collection.toursFailed': 'Túry tohto zoznamu sa nepodarilo načítať.',
'tours.collection.points': '{count} bodov trasy',
'tours.collection.points.one': '{count} bod trasy',
'tours.collection.points.few': '{count} body trasy',
'tours.collection.savedFrom': 'Uložené z',
'tours.collection.source': 'Uložené z „{tour}“ v „{trip}“',
'tours.collection.keptBy': 'Uložil(a) {name}',
'tours.collection.add': 'Pridať do cesty',
'tours.collection.addHint': 'Vytvorí vlastnú novú túru v ceste, ktorú zvolíš',
'tours.collection.remove': 'Odobrať zo zbierky',
'tours.collection.removeTitle': 'Odobrať túto túru zo zbierky?',
'tours.collection.removeBody': 'Túry, ktoré už z nej v tvojich cestách vznikli, zostanú, ako sú.',
'tours.collection.removed': 'Túra bola odobratá zo zbierky.',
'tours.collection.removeFailed': 'Túru sa nepodarilo odobrať.',
'tours.collection.takeEyebrow': 'Zo zbierky',
'tours.collection.takeSub': 'Stane sa vlastnou túrou zvolenej cesty, bez dní. Zbierka zostane, ako je.',
'tours.collection.intoTrip': 'Do cesty',
'tours.collection.noPermission': 'Do tejto cesty nemôžeš pridávať túry',
'tours.collection.noTrips': 'Zatiaľ nemáš žiadnu cestu, kam ju pridať.',
'tours.collection.withPoints': 'S {count} bodmi trasy',
'tours.collection.withPoints.one': 'S {count} bodom trasy',
'tours.collection.withPoints.few': 'S {count} bodmi trasy',
'tours.collection.withPointsHint': 'Štart, zastávky a cieľ prejdú vo svojom poradí.',
'tours.collection.lineOnly': 'Len línia',
'tours.collection.lineOnlyHint': 'Línia zostane, ako bola uložená. Nepridajú sa žiadne body trasy.',
'tours.collection.needsPoints': 'Táto túra zatiaľ nemá líniu, preto musia jej body trasy prejsť s ňou.',
'tours.collection.nameInTrip': 'Názov v ceste',
'tours.collection.taken': '„{name}“ je teraz túrou cesty „{trip}“.',
'tours.collection.takeFailed': 'Túru sa nepodarilo pridať do cesty.',
'tours.collection.openTrip': 'Otvoriť cestu',
};
export default tours;
+42
View File
@@ -1299,5 +1299,47 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Hela turen',
'tours.stages.totalPartial': 'känt för {known} av {of} etapper',
'tours.stages.openInPlanner': 'Öppna etappen i planeraren',
'tours.collection.save': 'Spara i en samling',
'tours.collection.saveHint': 'Sparar en fristående kopia av den här turen i en av dina samlingar',
'tours.collection.saveFirst': 'Spara turen först för att lägga den i en samling',
'tours.collection.saveSub': 'En fristående kopia. Senare ändringar av turen når den inte.',
'tours.collection.list': 'Samling',
'tours.collection.readOnly': 'Du kan bara läsa den här listan',
'tours.collection.noLists': 'Du har ingen samling än.',
'tours.collection.openCollections': 'Öppna samlingar',
'tours.collection.nameThere': 'Namn i samlingen',
'tours.collection.keep': 'Spara',
'tours.collection.kept': '”{name}” finns nu i ”{list}”.',
'tours.collection.keepFailed': 'Turen kunde inte sparas.',
'tours.collection.offline': 'Samlingar behöver en anslutning.',
'tours.collection.listsFailed': 'Samlingarna kunde inte läsas in.',
'tours.collection.toursFailed': 'Turerna i den här listan kunde inte läsas in.',
'tours.collection.points': '{count} vägpunkter',
'tours.collection.points.one': '{count} vägpunkt',
'tours.collection.savedFrom': 'Sparad från',
'tours.collection.source': 'Sparad från ”{tour}” i ”{trip}”',
'tours.collection.keptBy': 'Sparad av {name}',
'tours.collection.add': 'Lägg till i en resa',
'tours.collection.addHint': 'Skapar en egen ny tur i en resa du väljer',
'tours.collection.remove': 'Ta bort från samlingen',
'tours.collection.removeTitle': 'Ta bort den här turen från samlingen?',
'tours.collection.removeBody': 'Turer som redan gjorts av den i dina resor blir kvar som de är.',
'tours.collection.removed': 'Turen togs bort från samlingen.',
'tours.collection.removeFailed': 'Turen kunde inte tas bort.',
'tours.collection.takeEyebrow': 'Från samlingen',
'tours.collection.takeSub': 'Blir en egen tur i resan du väljer, utan dagar. Samlingen förblir som den är.',
'tours.collection.intoTrip': 'Till resan',
'tours.collection.noPermission': 'Du får inte lägga till turer i den här resan',
'tours.collection.noTrips': 'Du har ingen resa att lägga den i än.',
'tours.collection.withPoints': 'Med {count} vägpunkter',
'tours.collection.withPoints.one': 'Med {count} vägpunkt',
'tours.collection.withPointsHint': 'Start, stopp och mål följer med i sin ordning.',
'tours.collection.lineOnly': 'Bara linjen',
'tours.collection.lineOnlyHint': 'Linjen blir kvar som den sparades. Inga vägpunkter läggs till.',
'tours.collection.needsPoints': 'Turen har ingen linje än, så dess vägpunkter måste följa med.',
'tours.collection.nameInTrip': 'Namn i resan',
'tours.collection.taken': '”{name}” är nu en tur i ”{trip}”.',
'tours.collection.takeFailed': 'Turen kunde inte läggas till i resan.',
'tours.collection.openTrip': 'Öppna resan',
};
export default tours;
+40
View File
@@ -1244,5 +1244,45 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'ทั้งเส้นทาง',
'tours.stages.totalPartial': 'ทราบสำหรับ {known} จาก {of} ช่วง',
'tours.stages.openInPlanner': 'เปิดช่วงนี้ในตัววางแผน',
'tours.collection.save': 'เก็บไว้ในคอลเลกชัน',
'tours.collection.saveHint': 'เก็บสำเนาที่แยกเป็นอิสระของทัวร์นี้ไว้ในคอลเลกชันหนึ่งของคุณ',
'tours.collection.saveFirst': 'บันทึกทัวร์ก่อนเพื่อเก็บไว้ในคอลเลกชัน',
'tours.collection.saveSub': 'สำเนาที่แยกเป็นอิสระ การเปลี่ยนแปลงทัวร์นี้ภายหลังจะไม่ส่งผลถึงสำเนา',
'tours.collection.list': 'คอลเลกชัน',
'tours.collection.readOnly': 'คุณอ่านรายการนี้ได้อย่างเดียว',
'tours.collection.noLists': 'คุณยังไม่มีคอลเลกชัน',
'tours.collection.openCollections': 'เปิดคอลเลกชัน',
'tours.collection.nameThere': 'ชื่อในคอลเลกชัน',
'tours.collection.keep': 'เก็บ',
'tours.collection.kept': 'เก็บ “{name}” ไว้ใน “{list}” แล้ว',
'tours.collection.keepFailed': 'เก็บทัวร์ไม่ได้',
'tours.collection.offline': 'คอลเลกชันต้องใช้การเชื่อมต่อ',
'tours.collection.listsFailed': 'โหลดคอลเลกชันไม่ได้',
'tours.collection.toursFailed': 'โหลดทัวร์ของรายการนี้ไม่ได้',
'tours.collection.points': 'จุดแวะ {count} จุด',
'tours.collection.savedFrom': 'เก็บมาจาก',
'tours.collection.source': 'เก็บมาจาก “{tour}” ใน “{trip}”',
'tours.collection.keptBy': '{name} เป็นผู้เก็บ',
'tours.collection.add': 'เพิ่มลงในทริป',
'tours.collection.addHint': 'สร้างทัวร์ใหม่ของตัวเองในทริปที่คุณเลือก',
'tours.collection.remove': 'นำออกจากคอลเลกชัน',
'tours.collection.removeTitle': 'นำทัวร์นี้ออกจากคอลเลกชันหรือไม่',
'tours.collection.removeBody': 'ทัวร์ที่สร้างจากรายการนี้ในทริปของคุณแล้วจะยังคงเหมือนเดิม',
'tours.collection.removed': 'นำทัวร์ออกจากคอลเลกชันแล้ว',
'tours.collection.removeFailed': 'นำทัวร์ออกไม่ได้',
'tours.collection.takeEyebrow': 'จากคอลเลกชัน',
'tours.collection.takeSub': 'กลายเป็นทัวร์ของตัวเองในทริปที่คุณเลือก โดยไม่มีวัน คอลเลกชันยังคงเหมือนเดิม',
'tours.collection.intoTrip': 'ลงในทริป',
'tours.collection.noPermission': 'คุณเพิ่มทัวร์ลงในทริปนี้ไม่ได้',
'tours.collection.noTrips': 'คุณยังไม่มีทริปที่จะเพิ่มลงไป',
'tours.collection.withPoints': 'พร้อมจุดแวะ {count} จุด',
'tours.collection.withPointsHint': 'จุดเริ่ม จุดพัก และจุดสิ้นสุดมาตามลำดับเดิม',
'tours.collection.lineOnly': 'เฉพาะเส้นทาง',
'tours.collection.lineOnlyHint': 'เส้นทางคงเดิมตามที่บันทึกไว้ ไม่มีการเพิ่มจุดแวะ',
'tours.collection.needsPoints': 'ทัวร์นี้ยังไม่มีเส้นทาง จึงต้องนำจุดแวะมาด้วย',
'tours.collection.nameInTrip': 'ชื่อในทริป',
'tours.collection.taken': '“{name}” เป็นทัวร์ของ “{trip}” แล้ว',
'tours.collection.takeFailed': 'เพิ่มทัวร์ลงในทริปไม่ได้',
'tours.collection.openTrip': 'เปิดทริป',
};
export default tours;
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 1182;
const GENERAL_KEY_COUNT = 1222;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+43
View File
@@ -1306,5 +1306,48 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Tüm tur',
'tours.stages.totalPartial': '{of} etabın {known} tanesi için biliniyor',
'tours.stages.openInPlanner': 'Bu etabı planlayıcıda aç',
'tours.collection.save': 'Bir koleksiyonda sakla',
'tours.collection.saveHint': 'Bu turun bağımsız bir kopyasını koleksiyonlarınızdan birinde saklar',
'tours.collection.saveFirst': 'Koleksiyonda saklamak için önce turu kaydedin',
'tours.collection.saveSub': 'Bağımsız bir kopya. Bu turdaki sonraki değişiklikler ona ulaşmaz.',
'tours.collection.list': 'Koleksiyon',
'tours.collection.readOnly': 'Bu listeyi yalnızca okuyabilirsiniz',
'tours.collection.noLists': 'Henüz bir koleksiyonunuz yok.',
'tours.collection.openCollections': 'Koleksiyonları aç',
'tours.collection.nameThere': 'Koleksiyondaki ad',
'tours.collection.keep': 'Sakla',
'tours.collection.kept': '“{name}” artık “{list}” içinde.',
'tours.collection.keepFailed': 'Tur saklanamadı.',
'tours.collection.offline': 'Koleksiyonlar bağlantı gerektirir.',
'tours.collection.listsFailed': 'Koleksiyonlar yüklenemedi.',
'tours.collection.toursFailed': 'Bu listenin turları yüklenemedi.',
'tours.collection.points': '{count} ara nokta',
'tours.collection.points.one': '{count} ara nokta',
'tours.collection.savedFrom': 'Nereden saklandı',
'tours.collection.source': '“{trip}” gezisindeki “{tour}” turundan saklandı',
'tours.collection.keptBy': 'Saklayan: {name}',
'tours.collection.add': 'Bir geziye ekle',
'tours.collection.addHint': 'Seçtiğiniz bir gezide kendine ait yeni bir tur oluşturur',
'tours.collection.remove': 'Koleksiyondan çıkar',
'tours.collection.removeTitle': 'Bu tur koleksiyondan çıkarılsın mı?',
'tours.collection.removeBody': 'Gezilerinizde ondan daha önce oluşturulan turlar olduğu gibi kalır.',
'tours.collection.removed': 'Tur koleksiyondan çıkarıldı.',
'tours.collection.removeFailed': 'Tur çıkarılamadı.',
'tours.collection.takeEyebrow': 'Koleksiyondan',
'tours.collection.takeSub':
'Seçtiğiniz gezide günler olmadan kendine ait bir tur olur. Koleksiyon olduğu gibi kalır.',
'tours.collection.intoTrip': 'Geziye',
'tours.collection.noPermission': 'Bu geziye tur ekleyemezsiniz',
'tours.collection.noTrips': 'Henüz ekleyebileceğiniz bir geziniz yok.',
'tours.collection.withPoints': '{count} ara noktayla',
'tours.collection.withPoints.one': '{count} ara noktayla',
'tours.collection.withPointsHint': 'Başlangıç, duraklar ve bitiş kendi sıralarıyla gelir.',
'tours.collection.lineOnly': 'Yalnızca çizgi',
'tours.collection.lineOnlyHint': 'Çizgi kaydedildiği gibi kalır. Ara nokta eklenmez.',
'tours.collection.needsPoints': 'Bu turun henüz çizgisi yok, bu yüzden ara noktaları da gelmeli.',
'tours.collection.nameInTrip': 'Gezideki ad',
'tours.collection.taken': '“{name}” artık “{trip}” gezisinin bir turu.',
'tours.collection.takeFailed': 'Tur geziye eklenemedi.',
'tours.collection.openTrip': 'Geziyi aç',
};
export default tours;
+46
View File
@@ -1355,5 +1355,51 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Увесь маршрут',
'tours.stages.totalPartial': 'відомо для {known} з {of} етапів',
'tours.stages.openInPlanner': 'Відкрити цей етап у планувальнику',
'tours.collection.save': 'Зберегти в колекції',
'tours.collection.saveHint': 'Зберігає незалежну копію цього маршруту в одній із ваших колекцій',
'tours.collection.saveFirst': 'Спочатку збережіть маршрут, щоб покласти його в колекцію',
'tours.collection.saveSub': 'Незалежна копія. Подальші зміни маршруту її не торкнуться.',
'tours.collection.list': 'Колекція',
'tours.collection.readOnly': 'Цей список ви можете лише переглядати',
'tours.collection.noLists': 'У вас ще немає жодної колекції.',
'tours.collection.openCollections': 'Відкрити колекції',
'tours.collection.nameThere': 'Назва в колекції',
'tours.collection.keep': 'Зберегти',
'tours.collection.kept': '«{name}» тепер зберігається в «{list}».',
'tours.collection.keepFailed': 'Не вдалося зберегти маршрут.',
'tours.collection.offline': 'Для колекцій потрібне підключення.',
'tours.collection.listsFailed': 'Не вдалося завантажити колекції.',
'tours.collection.toursFailed': 'Не вдалося завантажити маршрути цього списку.',
'tours.collection.points': 'Точок маршруту: {count}',
'tours.collection.points.one': '{count} точка маршруту',
'tours.collection.points.few': '{count} точки маршруту',
'tours.collection.points.many': '{count} точок маршруту',
'tours.collection.savedFrom': 'Збережено з',
'tours.collection.source': 'Збережено з «{tour}» у «{trip}»',
'tours.collection.keptBy': 'Зберіг(ла): {name}',
'tours.collection.add': 'Додати до подорожі',
'tours.collection.addHint': 'Створює новий власний маршрут у вибраній подорожі',
'tours.collection.remove': 'Прибрати з колекції',
'tours.collection.removeTitle': 'Прибрати цей маршрут із колекції?',
'tours.collection.removeBody': 'Маршрути, вже створені з нього у ваших подорожах, залишаться як є.',
'tours.collection.removed': 'Маршрут прибрано з колекції.',
'tours.collection.removeFailed': 'Не вдалося прибрати маршрут.',
'tours.collection.takeEyebrow': 'З колекції',
'tours.collection.takeSub': 'Стане власним маршрутом вибраної подорожі, без днів. Колекція залишиться як є.',
'tours.collection.intoTrip': 'До подорожі',
'tours.collection.noPermission': 'Ви не можете додавати маршрути до цієї подорожі',
'tours.collection.noTrips': 'У вас ще немає подорожі, куди його додати.',
'tours.collection.withPoints': 'Точок маршруту: {count}',
'tours.collection.withPoints.one': 'З {count} точкою маршруту',
'tours.collection.withPoints.few': 'З {count} точками маршруту',
'tours.collection.withPoints.many': 'З {count} точками маршруту',
'tours.collection.withPointsHint': 'Старт, зупинки й фініш переходять у своєму порядку.',
'tours.collection.lineOnly': 'Лише лінія',
'tours.collection.lineOnlyHint': 'Лінія залишається такою, як її збережено. Точки маршруту не додаються.',
'tours.collection.needsPoints': 'Цей маршрут ще не має лінії, тому його точки мають перейти разом із ним.',
'tours.collection.nameInTrip': 'Назва в подорожі',
'tours.collection.taken': '«{name}» тепер маршрут подорожі «{trip}».',
'tours.collection.takeFailed': 'Не вдалося додати маршрут до подорожі.',
'tours.collection.openTrip': 'Відкрити подорож',
};
export default tours;
+41
View File
@@ -1288,5 +1288,46 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': 'Cả hành trình',
'tours.stages.totalPartial': 'đã biết cho {known} trên {of} chặng',
'tours.stages.openInPlanner': 'Mở chặng này trong trình lập kế hoạch',
'tours.collection.save': 'Giữ trong bộ sưu tập',
'tours.collection.saveHint': 'Giữ một bản sao độc lập của chuyến này trong một bộ sưu tập của bạn',
'tours.collection.saveFirst': 'Hãy lưu chuyến trước để giữ nó trong bộ sưu tập',
'tours.collection.saveSub': 'Một bản sao độc lập. Các thay đổi sau này của chuyến không ảnh hưởng đến nó.',
'tours.collection.list': 'Bộ sưu tập',
'tours.collection.readOnly': 'Bạn chỉ có thể xem danh sách này',
'tours.collection.noLists': 'Bạn chưa có bộ sưu tập nào.',
'tours.collection.openCollections': 'Mở bộ sưu tập',
'tours.collection.nameThere': 'Tên trong bộ sưu tập',
'tours.collection.keep': 'Giữ',
'tours.collection.kept': '“{name}” giờ được giữ trong “{list}”.',
'tours.collection.keepFailed': 'Không giữ được chuyến.',
'tours.collection.offline': 'Bộ sưu tập cần kết nối mạng.',
'tours.collection.listsFailed': 'Không tải được các bộ sưu tập.',
'tours.collection.toursFailed': 'Không tải được các chuyến của danh sách này.',
'tours.collection.points': '{count} điểm tham chiếu',
'tours.collection.savedFrom': 'Giữ từ',
'tours.collection.source': 'Giữ từ “{tour}” trong “{trip}”',
'tours.collection.keptBy': 'Do {name} giữ',
'tours.collection.add': 'Thêm vào chuyến đi',
'tours.collection.addHint': 'Tạo một chuyến mới riêng trong chuyến đi bạn chọn',
'tours.collection.remove': 'Bỏ khỏi bộ sưu tập',
'tours.collection.removeTitle': 'Bỏ chuyến này khỏi bộ sưu tập?',
'tours.collection.removeBody': 'Các chuyến đã tạo từ nó trong các chuyến đi của bạn vẫn giữ nguyên.',
'tours.collection.removed': 'Đã bỏ chuyến khỏi bộ sưu tập.',
'tours.collection.removeFailed': 'Không bỏ được chuyến.',
'tours.collection.takeEyebrow': 'Từ bộ sưu tập',
'tours.collection.takeSub':
'Trở thành một chuyến riêng trong chuyến đi bạn chọn, không có ngày. Bộ sưu tập giữ nguyên.',
'tours.collection.intoTrip': 'Vào chuyến đi',
'tours.collection.noPermission': 'Bạn không được thêm chuyến vào chuyến đi này',
'tours.collection.noTrips': 'Bạn chưa có chuyến đi nào để thêm vào.',
'tours.collection.withPoints': 'Kèm {count} điểm tham chiếu',
'tours.collection.withPointsHint': 'Điểm đầu, các điểm dừng và điểm cuối đi theo đúng thứ tự.',
'tours.collection.lineOnly': 'Chỉ đường nét',
'tours.collection.lineOnlyHint': 'Đường nét giữ như khi lưu. Không thêm điểm tham chiếu nào.',
'tours.collection.needsPoints': 'Chuyến này chưa có đường nét, nên các điểm tham chiếu phải đi cùng.',
'tours.collection.nameInTrip': 'Tên trong chuyến đi',
'tours.collection.taken': '“{name}” giờ là một chuyến của “{trip}”.',
'tours.collection.takeFailed': 'Không thêm được chuyến vào chuyến đi.',
'tours.collection.openTrip': 'Mở chuyến đi',
};
export default tours;
+40
View File
@@ -1203,5 +1203,45 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': '整條路線',
'tours.stages.totalPartial': '{of} 個階段中 {known} 個已知',
'tours.stages.openInPlanner': '在規劃器中開啟此階段',
'tours.collection.save': '保存到收藏',
'tours.collection.saveHint': '將這條路線的獨立副本保存到你的某個收藏中',
'tours.collection.saveFirst': '請先儲存路線,再放入收藏',
'tours.collection.saveSub': '獨立副本。之後對這條路線的修改不會影響它。',
'tours.collection.list': '收藏',
'tours.collection.readOnly': '你只能檢視此清單',
'tours.collection.noLists': '你還沒有任何收藏。',
'tours.collection.openCollections': '開啟收藏',
'tours.collection.nameThere': '在收藏中的名稱',
'tours.collection.keep': '保存',
'tours.collection.kept': '「{name}」已保存到「{list}」。',
'tours.collection.keepFailed': '無法保存路線。',
'tours.collection.offline': '收藏需要網路連線。',
'tours.collection.listsFailed': '無法載入收藏。',
'tours.collection.toursFailed': '無法載入此清單的路線。',
'tours.collection.points': '{count} 個途經點',
'tours.collection.savedFrom': '保存自',
'tours.collection.source': '保存自「{trip}」中的「{tour}」',
'tours.collection.keptBy': '由 {name} 保存',
'tours.collection.add': '加入行程',
'tours.collection.addHint': '在你選擇的行程中新增一條獨立路線',
'tours.collection.remove': '從收藏中移除',
'tours.collection.removeTitle': '要從收藏中移除這條路線嗎?',
'tours.collection.removeBody': '已在你的行程中由它建立的路線保持不變。',
'tours.collection.removed': '已從收藏中移除路線。',
'tours.collection.removeFailed': '無法移除路線。',
'tours.collection.takeEyebrow': '來自收藏',
'tours.collection.takeSub': '在所選行程中成為一條獨立路線,不分配日期。收藏保持不變。',
'tours.collection.intoTrip': '加入的行程',
'tours.collection.noPermission': '你不能在此行程加入路線',
'tours.collection.noTrips': '你還沒有可以加入的行程。',
'tours.collection.withPoints': '連同 {count} 個途經點',
'tours.collection.withPointsHint': '起點、中途點和終點依原順序一起帶入。',
'tours.collection.lineOnly': '僅線路',
'tours.collection.lineOnlyHint': '線路維持儲存時的樣子,不加入任何途經點。',
'tours.collection.needsPoints': '這條路線還沒有線路,因此必須帶上它的途經點。',
'tours.collection.nameInTrip': '在行程中的名稱',
'tours.collection.taken': '「{name}」現在是「{trip}」的一條路線。',
'tours.collection.takeFailed': '無法將路線加入行程。',
'tours.collection.openTrip': '開啟行程',
};
export default tours;
+40
View File
@@ -1204,5 +1204,45 @@ const tours: TranslationStrings = {
'tours.stages.wholeTour': '整条路线',
'tours.stages.totalPartial': '{of} 个阶段中 {known} 个已知',
'tours.stages.openInPlanner': '在规划器中打开此阶段',
'tours.collection.save': '保存到收藏',
'tours.collection.saveHint': '将这条路线的独立副本保存到你的某个收藏中',
'tours.collection.saveFirst': '请先保存路线,再放入收藏',
'tours.collection.saveSub': '独立副本。之后对这条路线的修改不会影响它。',
'tours.collection.list': '收藏',
'tours.collection.readOnly': '你只能查看此列表',
'tours.collection.noLists': '你还没有任何收藏。',
'tours.collection.openCollections': '打开收藏',
'tours.collection.nameThere': '在收藏中的名称',
'tours.collection.keep': '保存',
'tours.collection.kept': '“{name}”已保存到“{list}”。',
'tours.collection.keepFailed': '无法保存路线。',
'tours.collection.offline': '收藏需要网络连接。',
'tours.collection.listsFailed': '无法加载收藏。',
'tours.collection.toursFailed': '无法加载此列表的路线。',
'tours.collection.points': '{count} 个途经点',
'tours.collection.savedFrom': '保存自',
'tours.collection.source': '保存自“{trip}”中的“{tour}”',
'tours.collection.keptBy': '由 {name} 保存',
'tours.collection.add': '添加到行程',
'tours.collection.addHint': '在你选择的行程中新建一条独立路线',
'tours.collection.remove': '从收藏中移除',
'tours.collection.removeTitle': '要从收藏中移除这条路线吗?',
'tours.collection.removeBody': '已在你的行程中由它创建的路线保持不变。',
'tours.collection.removed': '已从收藏中移除路线。',
'tours.collection.removeFailed': '无法移除路线。',
'tours.collection.takeEyebrow': '来自收藏',
'tours.collection.takeSub': '在所选行程中成为一条独立路线,不分配日期。收藏保持不变。',
'tours.collection.intoTrip': '添加到的行程',
'tours.collection.noPermission': '你不能向此行程添加路线',
'tours.collection.noTrips': '你还没有可以添加的行程。',
'tours.collection.withPoints': '连同 {count} 个途经点',
'tours.collection.withPointsHint': '起点、中途点和终点按原顺序一起带入。',
'tours.collection.lineOnly': '仅线路',
'tours.collection.lineOnlyHint': '线路保持保存时的样子,不添加任何途经点。',
'tours.collection.needsPoints': '这条路线还没有线路,因此必须带上它的途经点。',
'tours.collection.nameInTrip': '在行程中的名称',
'tours.collection.taken': '“{name}”现在是“{trip}”的一条路线。',
'tours.collection.takeFailed': '无法将路线添加到行程。',
'tours.collection.openTrip': '打开行程',
};
export default tours;
+1
View File
@@ -63,6 +63,7 @@ export * from './tours/tour-activity.schema';
export * from './tours/tour-activity-stats.schema';
export * from './tours/tour-activity-stats';
export * from './tours/tour-library.schema';
export * from './tours/collection-tour.schema';
export * from './tours/tour-poi-search.schema';
export * from './tours/tour-trail-grade.schema';
export * from './tours/tour-walk.schema';
@@ -0,0 +1,85 @@
import {
collectionTourDetailResponseSchema,
collectionTourListResponseSchema,
collectionTourSaveRequestSchema,
collectionTourTakeOverRequestSchema,
} from './collection-tour.schema';
import { describe, expect, it } from 'vitest';
const item = {
id: 4,
collection_id: 9,
name: 'Kramerplateauweg',
description: null,
tour_type: 'hike',
distance: 4.8,
elevation_gain: 533,
elevation_loss: 530,
duration: 156,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 1,
wanderer_ref: null,
origin: 'trek',
has_line: true,
waypoint_count: 3,
source: { tripTitle: 'Garmisch', tourName: 'Kramer' },
saved_by: 'maurice',
saved_at: '2026-10-20 10:00:00',
};
describe('collection tour contracts (LIB-03)', () => {
it('saves a tour of a trip under its own name or a new one, nothing else', () => {
expect(collectionTourSaveRequestSchema.parse({ source_trip_id: 2, source_place_id: 7, name: ' Kept ' }).name).toBe(
'Kept',
);
expect(collectionTourSaveRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7 }).success).toBe(true);
expect(collectionTourSaveRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, name: '' }).success).toBe(
false,
);
expect(collectionTourSaveRequestSchema.safeParse({ source_trip_id: '2', source_place_id: 7 }).success).toBe(false);
expect(
collectionTourSaveRequestSchema.safeParse({ source_trip_id: 2, source_place_id: 7, collection_id: 1 }).success,
).toBe(false);
});
it('takes a kept tour into a trip only once the choice about its points is said', () => {
const accepts = (body: unknown) => collectionTourTakeOverRequestSchema.safeParse(body).success;
const body = { collection_id: 9, collection_tour_id: 4, include_waypoints: false };
expect(collectionTourTakeOverRequestSchema.parse(body)).toEqual(body);
expect(accepts({ collection_id: 9, collection_tour_id: 4 })).toBe(false);
expect(accepts({ collection_tour_id: 4, include_waypoints: true })).toBe(false);
expect(accepts({ ...body, include_waypoints: 'yes' })).toBe(false);
expect(accepts({ ...body, day_id: 1 })).toBe(false);
});
it('lists kept tours with where they came from, unknown measures staying unknown', () => {
const answer = collectionTourListResponseSchema.parse({
tours: [item, { ...item, id: 5, distance: null, saved_by: null }],
});
expect(answer.tours[1]?.distance).toBeNull();
expect(answer.tours[1]?.saved_by).toBeNull();
expect(collectionTourListResponseSchema.safeParse({ tours: [{ ...item, source: undefined }] }).success).toBe(false);
expect(collectionTourListResponseSchema.safeParse({ tours: [{ ...item, origin: 'komoot' }] }).success).toBe(false);
});
it('previews the line, the points in order and the trips it could go into', () => {
const detail = collectionTourDetailResponseSchema.parse({
tour: item,
line: [[47.4, 11.1]],
points: [
{ role: 'start', name: null, shaping: false },
{ role: 'end', name: 'Hut', shaping: false },
],
trips: [{ id: 3, title: 'Alps', start_date: null, end_date: null, can_add: true }],
});
expect(detail.points[1]?.name).toBe('Hut');
expect(
collectionTourDetailResponseSchema.safeParse({
...detail,
points: [{ role: 'middle', name: null, shaping: false }],
}).success,
).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { idSchema } from '../common/primitives.schema';
import { tourOriginSchema } from './tour-provenance.schema';
import { tourListItemSchema, tourNameSchema, tourWaypointRoleSchema } from './tours.schema';
import { z } from 'zod';
/**
* LIB-03: a whole tour kept in a collection, apart from any trip. Saving one
* makes an independent snapshot of a saved tour: its line, type, measures,
* settings and control points as they are at that moment, and the trip and
* tour it was saved from as they were called then. Taking it into a trip makes
* a new tour of its own there. Nothing keeps the tour it came from, the
* snapshot and a tour made from it in step with one another.
*/
/** The most points a preview's line carries, thinned evenly along it. */
export const COLLECTION_TOUR_PREVIEW_POINTS = 400;
/** Which tour of which trip to keep in the collection, and the name it gets there (the tour's own when left out). */
export const collectionTourSaveRequestSchema = z
.object({
source_trip_id: idSchema,
source_place_id: idSchema,
name: tourNameSchema.optional(),
})
.strict();
export type CollectionTourSaveRequest = z.infer<typeof collectionTourSaveRequestSchema>;
/**
* Which tour of which collection to take into the trip, whether its control
* points come along, and the name the new tour gets (the kept one's when left
* out). The choice about the points is never assumed: it has to be said.
*/
export const collectionTourTakeOverRequestSchema = z
.object({
collection_id: idSchema,
collection_tour_id: idSchema,
include_waypoints: z.boolean(),
name: tourNameSchema.optional(),
})
.strict();
export type CollectionTourTakeOverRequest = z.infer<typeof collectionTourTakeOverRequestSchema>;
/** A tour kept in a collection as the collection lists it: its measures as they were saved, null staying null. */
export const collectionTourItemSchema = tourListItemSchema
.pick({
name: true,
description: true,
tour_type: true,
distance: true,
elevation_gain: true,
elevation_loss: true,
duration: true,
planned_duration_minutes: true,
break_additional_minutes: true,
max_hiking_difficulty: true,
wanderer_ref: true,
station_count: true,
station_stay_minutes: true,
station_stays_unknown: true,
})
.extend({
id: idSchema,
collection_id: idSchema,
origin: tourOriginSchema,
/** False for a city walk kept with its stations only, its way not planned yet. */
has_line: z.boolean(),
/** The control points kept with it; shaping points count too. */
waypoint_count: z.number().int().nonnegative(),
/** The trip and tour it was saved from, as they were called when it was saved. */
source: z.object({ tripTitle: z.string(), tourName: z.string() }),
/** Who saved it; null once that account is gone. */
saved_by: z.string().nullable(),
saved_at: z.string(),
});
export type CollectionTourItem = z.infer<typeof collectionTourItemSchema>;
export const collectionTourListResponseSchema = z.object({
tours: z.array(collectionTourItemSchema),
});
export type CollectionTourListResponse = z.infer<typeof collectionTourListResponseSchema>;
export const collectionTourSaveResponseSchema = z.object({
tour: collectionTourItemSchema,
});
export type CollectionTourSaveResponse = z.infer<typeof collectionTourSaveResponseSchema>;
/** One kept control point as the preview shows it, in route order. */
export const collectionTourPointSchema = z.object({
role: tourWaypointRoleSchema,
name: z.string().nullable(),
/** A point there only to steer the route, not a stop. */
shaping: z.boolean(),
});
export type CollectionTourPoint = z.infer<typeof collectionTourPointSchema>;
/** A trip the user can open, and whether they may add a tour to it. */
export const collectionTourTargetSchema = z.object({
id: idSchema,
title: z.string(),
start_date: z.string().nullable(),
end_date: z.string().nullable(),
can_add: z.boolean(),
});
export type CollectionTourTarget = z.infer<typeof collectionTourTargetSchema>;
/**
* Everything the take-over asks about, read anew when it opens: the kept tour,
* its line thinned for drawing, its points in order, and the trips it could go
* into, newest first.
*/
export const collectionTourDetailResponseSchema = z.object({
tour: collectionTourItemSchema,
line: z.array(z.tuple([z.number(), z.number()])),
points: z.array(collectionTourPointSchema),
trips: z.array(collectionTourTargetSchema),
});
export type CollectionTourDetailResponse = z.infer<typeof collectionTourDetailResponseSchema>;
+8
View File
@@ -124,6 +124,14 @@ A few things deliberately stay behind, because they belong to the instance the f
A file that is neither a TREK list nor a GPX is refused with a reason. A single place inside a file that cannot be read is skipped and reported, so one bad line does not cost you the rest of the list.
## Whole tours
With the Tours addon on as well, a list can keep whole tours beside its places.
- **Keeping a tour**: open a saved tour in a trip (its detail, or the planner) and use the bookmark button, **Keep in a collection**. Pick a list you may add to (a list you can only read is shown dimmed) and the name it is kept under. The list keeps an independent copy: the line, the tour type, every measure and setting, and its waypoints in order with their start, stop and end roles. Its days, its links to places of the trip and the obstacle decisions taken there stay in the trip. Later changes to the tour do not reach the kept copy, and the trip is not changed.
- **On the list**: kept tours show as cards above the places, with their measures, how many waypoints they keep and the trip they were saved from. The owner or an admin of the list can remove one; tours already made from it stay as they are.
- **Taking it into a trip**: **Add to a trip** on a card shows the kept tour on a map and lists your trips; a trip you may not add tours to is dimmed. Say whether the waypoints come along: **With its waypoints** brings them in their order with their roles, **Line only** keeps just the line as it was saved and adds no waypoints. A tour kept without a line (a city walk whose way was not planned yet) has to bring its waypoints. The new tour is a tour of its own in that trip, with no days, and names the trip and tour it was first saved from. Neither the list nor the original tour changes.
## See also
- [Addons-Overview](Addons-Overview)
+6 -1
View File
@@ -233,7 +233,7 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T
### Tours _(Tours addon required)_
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track` and `set_tour_activity_excluded` write; every other tool here only reads. Tours someone is still drawing in the planner are not saved yet and do not show here.
The saved tours of a trip, as the Tours list and the tour detail show them, how far a city walk got on a day, paddle legs planned on the water, a city walk prepared from a briefing, a change to a saved city walk prepared for review and saved once the traveller confirmed it, a saved change taken back the same way, and the tours the user has done, summed across their trips as the Atlas shows them. `take_over_tour`, `create_city_walk`, `save_tour_change`, `undo_tour_change`, `assign_tour_track`, `unassign_tour_track`, `set_tour_activity_excluded`, `save_tour_to_collection`, `take_over_collection_tour` and `delete_collection_tour` write; every other tool here only reads. The collection tools need the Collections addon as well. Tours someone is still drawing in the planner are not saved yet and do not show here.
| Tool | Description | Scope |
|---|---|---|
@@ -249,6 +249,11 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `get_tour_activity_stats` | The tours the user has done across the trips they can open, summed: how many, distance, ascent and descent, as a whole and by type, for a period (`from`, `to`) and one type. A tour counts once the last day it is on is over, and only where the user takes part: named among the participants of that day, or nobody named, which means everyone of the trip; a day naming only others is theirs. Each tour says what its values rest on: `plan` (the planning of the trip, not a measurement) or `track` (a linked actual track, which replaces the plan instead of adding to it). A tour on several days counts once, an import other tours were planned from does not count beside them while one of them counts (`derived_from_place_id` names it), a paddle tour sums no heights, and an unknown value stays unknown. Tours the user left out are listed but not summed; `uncounted` names those of their trips on no day or on days without a date | `trips:read` |
| `set_tour_activity_excluded` | Leaves a tour out of the user's own activity sums, or counts it again, for instance a tour that fell through. The tour, its days and its plan do not change, and nobody else's sums do | `trips:write` |
| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours, and none of the visits of the other trip's days. Needs `place_edit` on the trip it writes into | `trips:write` |
| `list_collection_tours` | The whole tours kept in a collection, newest first: name, type, distance, ascent, descent, planned times, how many waypoints each keeps, whether it has a line, the trip and tour it was saved from and who saved it. Each is an independent copy; later changes to the tour it came from do not reach it. Needs the Collections addon too | `collections:read` |
| `get_collection_tour` | One tour kept in a collection with what taking it into a trip asks about: its line thinned to at most 400 points, its waypoints in order with their roles, and every trip the user can open with whether they may add a tour to it | `collections:read`, `trips:read` |
| `save_tour_to_collection` | Keeps a saved tour of a trip in a collection the user may add to, as an independent copy: line, type, measures, settings and waypoints with their roles, and where it came from. Its days, links to places of the trip and obstacle decisions stay in the trip; the trip is not changed | `collections:write`, `trips:read` |
| `take_over_collection_tour` | Makes a new tour of a trip from a tour kept in a collection. `include_waypoints` must be given: true brings the waypoints in order with their roles, false keeps only the line as saved (a kept tour without a line needs its waypoints). No days and no places are added; neither the collection nor the original tour changes. Needs `place_edit` on the trip | `trips:write`, `collections:read` |
| `delete_collection_tour` | Removes a tour kept in a collection; only the collection's owner or an admin of it may. Tours already made from it stay | `collections:write` |
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |
| `propose_city_walk` | Proposes a city walk from a briefing without saving anything: start (and end), the time frame on a day, the themes (`culture`, `parks`, `food`, `mixed`), the stations the traveller wished for, the places of the trip it may use and fixed appointments. Places for the themes come from the same search as `search_tour_pois`, up to 8, each with its source, theme and category; nothing is made up. Answers the stations in order with the walking distance and time from the one before, the arrival and leave times, the stay where the traveller gave it (unknown otherwise, which makes later arrivals the earliest possible), and the opening hours checked against the arrival on that day. The order is chosen on estimates; the walk in that order is then planned on foot by the router TREK uses for road trips (the traveller's own routing server where they set one), whose legs give the distances and times. Where it gives no way, because the router cannot be reached or a station lies more than 300 m off every way, the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. Walking and stays are kept apart, a complete total only when every stay is known, with the minutes the frame leaves for the stays not known. Lists free time, the wishes and appointments that cannot be kept as asked, what was left out and why, and `stations_for_create` for `create_city_walk` | `trips:read` |
| `create_city_walk` | Creates a city walk from stations the traveller confirmed (for instance `stations_for_create` of `propose_city_walk`), through the same creation the planner's save uses: a key for every station, its name, stay, importance, protection, opening hours and the place of the trip it stands for. The way on foot is planned by the same router as for the proposal, and the walk is saved with it, its distance and its walking time. Where the router gives no way, the walk is saved with its stations only (`route_pending`), says why, and has no line, distance or walking time until someone opens it in the planner, where TREK plans the way on foot and the walk can be saved with it. Needs `place_edit` on the trip | `trips:write` |