feat(tours): point a tour at its trip's packing list

A tour can now point at the packing list of its trip, as a whole or at one
of its categories (say "Hiking gear" for a hike), and shows it in its detail.
The new Packing list section reads the list live as the user may see it: the
backpack tile with the item count on its corner and the category's own
colour, how many are packed with a thin progress bar, the first items ticked
off or not with their quantity, how many more there are, and a button that
opens the trip's Lists tab. Who may change the tour links one, changes it or
lets go through a dialog that lists the whole list and every category with
its colour and item count, and writes only on its confirm.

It is a reference, never a copy: the items stay in Packing, are ticked off
and edited there, and each traveller sees in the tour only the items Packing
shows them, so an item kept to oneself never shows through a tour. A
category that is renamed, cleared or only holds items of others reads as
missing, an empty one as empty, a link that could not be read says so with a
retry, and while Lists are switched off the link stays, dimmed, and can
still be let go. Removing the link, or the tour, leaves the list and every
item as they are. Nothing suggests or changes an item by itself.

On the server the link is a row of its own in the tours domain
(tour_packing_links, migration 046110), one per tour and gone with it.
GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it
with the trip and change it with place_edit; linking needs the Lists addon
and, for a category, an item of it the user can see. The writes answer the
tour with its link and tell the trip through tours:changed, so the offline
client queues them as writes of the tour and replays them like one. A copied
trip keeps its tours' links, a tour saved as new or taken over does not.
The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run
over the same service and checks; the read also hands the tour's facts as
context for suggesting what to pack, which only becomes an item through the
packing tools. The empty category placeholder now lives in @trek/shared so
both sides leave it out alike. All 27 locales carry the new strings, and
the wiki describes the link and the tools.
This commit is contained in:
Maurice
2026-10-20 13:56:42 +02:00
parent 65fe8cd6e5
commit f833a05461
106 changed files with 3661 additions and 22 deletions
+20
View File
@@ -0,0 +1,20 @@
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
import apiClient from './client';
/**
* Where a tour's packing link lives; a write queued offline replays to the
* same path, `{id}` standing for a tour that has not synced yet.
*/
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
return `/trips/${tripId}/tours/${placeId}/packing-link`;
}
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
export const tourPackingLinkApi = {
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
};
@@ -15,8 +15,9 @@ export const KAT_COLORS = [
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
export const PACKING_PLACEHOLDER_NAME = '...'
// rejects empty names. Treat it as a placeholder in the UI. Shared, because a
// tour's packing link (P2D-XD-01) leaves it out the same way.
export { PACKING_PLACEHOLDER_NAME } from '@trek/shared'
/** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */
export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown'
@@ -53,6 +53,7 @@ import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/tourActivityCompare';
import { TourSaveToCollectionButton } from './collection/TourSaveToCollectionButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { TourPackingSection } from './packing/TourPackingSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
import { RunHeadPills } from './run/RunHeadPills';
@@ -94,6 +95,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
onSaveAsNew?: (tour: TourListItem) => Promise<void> | void;
/** ACT-01: a recorded track was assigned, taken off or imported; the lists take in these tours. */
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
/** P2D-XD-01: opens the trip's packing list the tour points at. */
onOpenPacking?: () => void;
onAssignToDay?: (placeId: number, dayId?: number) => void;
onRemoveAssignment?: (dayId: number, assignmentId: number) => void;
onDelete?: () => void;
@@ -502,6 +505,7 @@ export default function TourDetailDialog({
onSourceReimported,
onSaveAsNew,
onToursChanged,
onOpenPacking,
onAssignToDay,
onRemoveAssignment,
onDelete,
@@ -756,6 +760,13 @@ export default function TourDetailDialog({
onToursChanged={onToursChanged}
/>
)}
<TourPackingSection
tripId={place.trip_id}
tour={tour}
tourName={place.name}
canEdit={canEdit}
onOpenPacking={onOpenPacking}
/>
{description && (
<DialogSection className="flex-none" label={t('places.formDescription')}>
@@ -0,0 +1,151 @@
import { Backpack, Link2, ListChecks, Loader2, Unlink } from 'lucide-react';
import { useId, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { ChoiceList, ChoiceRow } from '../collection/choiceParts';
import { CategoryDot, CountBadge } from './packingLinkParts';
import type { TourPackingLinkController } from './useTourPackingLink';
/** One thing to point at: its mark, its name and how many items it holds. */
function Choice({
selected,
onChoose,
testId,
mark,
name,
count,
}: {
selected: boolean;
onChoose: () => void;
testId: string;
mark: ReactNode;
name: string;
count: number;
}) {
return (
<ChoiceRow selected={selected} disabled={false} disabledHint="" onChoose={onChoose} testId={testId}>
{mark}
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{name}
</span>
<CountBadge count={count} />
</ChoiceRow>
);
}
/**
* P2D-XD-01: the dialog that points a tour at its trip's packing list, as a
* whole or at one of its categories, each with its colour and how many items
* it holds. It writes only through the button in its foot; letting go of a
* link is offered there too, and neither touches the list itself.
*/
export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPackingLinkController; tourName: string }) {
const { t } = useTranslation();
const labelId = useId();
if (!packing.open) return null;
return (
<DialogShell
onClose={packing.close}
labelledBy={labelId}
width="narrow"
align="top"
blocked={packing.saving}
header={
<DialogHeader
tile={
<DialogTile>
<Backpack size={20} strokeWidth={1.9} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={packing.close}
eyebrow={t('tours.packing.link')}
title={tourName}
sub={t('tours.packing.dialogSub')}
subWraps
/>
}
footer={
<DialogFooter>
{packing.link && (
<Tooltip label={t('tours.packing.unlinkHint')}>
<span className="inline-flex">
<DialogButton
onClick={() => void packing.unlink()}
disabled={packing.saving}
data-testid="tour-packing-unlink"
icon={<Unlink size={14} strokeWidth={2} />}
>
{t('tours.packing.unlink')}
</DialogButton>
</span>
</Tooltip>
)}
<FooterSpacer />
<DialogButton onClick={packing.close} disabled={packing.saving}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-packing-confirm"
disabled={!packing.canConfirm}
onClick={() => void packing.confirm()}
icon={
packing.saving ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<Link2 size={14} strokeWidth={2} />
)
}
>
{t('tours.packing.confirm')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('tours.packing.choose')}>
<ChoiceList label={t('tours.packing.choose')} tall>
<Choice
selected={packing.choice === null}
onChoose={() => packing.choose(null)}
testId="tour-packing-choice-whole"
mark={
<span className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm">
<ListChecks size={13} strokeWidth={2.2} />
</span>
}
name={t('tours.packing.whole')}
count={packing.wholeCount}
/>
{packing.categories.map((category) => (
<Choice
key={category.name}
selected={packing.choice === category.name}
onChoose={() => packing.choose(category.name)}
testId={`tour-packing-choice-${category.name}`}
mark={
<span className="grid h-6 w-6 flex-none place-items-center">
<CategoryDot color={category.color} />
</span>
}
name={category.name}
count={category.count}
/>
))}
</ChoiceList>
</DialogSection>
</DialogShell>
);
}
@@ -0,0 +1,260 @@
import type { TourListItem, TourPackingLink } from '@trek/shared';
import { AlertTriangle, ArrowUpRight, Backpack, Check, Link2, PackageOpen, PowerOff, Unlink } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { PackingItem } from '../../../types';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourNotice } from '../tourParts';
import { CategoryDot } from './packingLinkParts';
import { TourPackingLinkDialog } from './TourPackingLinkDialog';
import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink';
/** How many items of the list the card shows before it says how many more there are. */
const SHOWN_ITEMS = 5;
/** The quiet pill beside the section's label, like the upload of a tour's files. */
function SectionPill({ onClick, icon, children }: { onClick: () => void; icon: ReactNode; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-[2px] font-geist font-semibold text-content-muted shadow-sm transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(10.5)}
>
{icon}
{children}
</button>
);
}
/** The backpack tile with the item count on its corner, the way a waypoint's number carries its marks. */
function PackTile({ count, color }: { count: number; color: string | null }) {
return (
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
<Backpack size={16} strokeWidth={2} aria-hidden="true" />
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9.5)}
>
{count}
</span>
{color && (
<span className="absolute -bottom-0.5 -start-0.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card">
<CategoryDot color={color} />
</span>
)}
</span>
);
}
/** One item as Packing keeps it, read only: ticked off or not, its name and how many. */
function ItemRow({ item }: { item: PackingItem }) {
return (
<li className="flex min-w-0 items-center gap-2 py-1">
<span
className={`grid h-4 w-4 flex-none place-items-center rounded-full ${item.checked ? 'bg-accent text-accent-text' : 'ring-1 ring-inset ring-edge'}`}
>
{!!item.checked && <Check size={10} strokeWidth={3} aria-hidden="true" />}
</span>
<span
className={`min-w-0 flex-1 truncate ${item.checked ? 'text-content-faint line-through' : 'text-content-secondary'}`}
style={fs(12, 'body')}
>
{item.name}
</span>
{(item.quantity ?? 1) > 1 && <WhiteBadge>×{item.quantity}</WhiteBadge>}
</li>
);
}
/** The name of what the tour points at: a category by its own name, otherwise the whole list. */
function linkName(link: TourPackingLink, t: (key: string) => string): string {
return link.category ?? t('tours.packing.whole');
}
/** The linked list as it reads now: its items with how many are packed, or why there are none to show. */
function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkController; onOpenPacking?: () => void }) {
const { t } = useTranslation();
const { link, linked } = packing;
if (!link || !linked) return null;
const name = linkName(link, t);
if (linked.state === 'missing') {
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.packing.missing', { name })}
</TourNotice>
);
}
const total = linked.items.length;
const allPacked = total > 0 && linked.packed === total;
const more = total - SHOWN_ITEMS;
return (
<div className={`${BOX} flex flex-col gap-2.5 px-3 py-2.5`} data-testid="tour-packing-card">
<div className="flex min-w-0 items-center gap-2.5">
<PackTile count={total} color={packing.linkedColor} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{name}
</p>
{total > 0 && (
<p className="m-0 truncate text-content-faint" style={fs(11)}>
{t('tours.packing.packed', { packed: linked.packed, count: total })}
</p>
)}
</div>
{allPacked && (
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
{t('tours.packing.allPacked')}
</SoftPill>
)}
</div>
{total > 0 ? (
<>
<div className="h-1 overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
<div
className="h-full rounded-full bg-accent transition-[width] duration-300"
style={{ width: `${(linked.packed / total) * 100}%` }}
/>
</div>
<ul className="m-0 flex list-none flex-col p-0">
{linked.items.slice(0, SHOWN_ITEMS).map((item) => (
<ItemRow key={item.id} item={item} />
))}
</ul>
{more > 0 && (
<p className="m-0 text-content-faint" style={fs(11)}>
{t('tours.packing.more', { count: more })}
</p>
)}
</>
) : (
<p className="m-0 flex items-center gap-1.5 text-content-muted" style={fs(12, 'body')}>
<PackageOpen size={13} strokeWidth={2} className="flex-none text-content-faint" />
{link.category === null ? t('tours.packing.emptyList') : t('tours.packing.emptyCategory', { name })}
</p>
)}
{onOpenPacking && (
<div className="flex">
<CardAction icon={<ArrowUpRight size={13} strokeWidth={2.2} />} onClick={onOpenPacking}>
{t('tours.packing.open')}
</CardAction>
</div>
)}
</div>
);
}
/** No link yet: a quiet dashed card, with the way to make one for who may change the tour. */
function UnlinkedCard({ onLink }: { onLink?: () => void }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2.5 rounded-[10px] border border-dashed border-edge px-3 py-2.5">
<span className="grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-faint">
<Backpack size={16} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 text-content-faint" style={fs(12, 'body')}>
{t('tours.packing.none')}
</span>
{onLink && (
<CardAction icon={<Link2 size={13} strokeWidth={2.2} />} onClick={onLink}>
{t('tours.packing.link')}
</CardAction>
)}
</div>
);
}
/** The section's body for whatever the link reads as now. */
function SectionBody({
packing,
canEdit,
onOpenPacking,
}: {
packing: TourPackingLinkController;
canEdit: boolean;
onOpenPacking?: () => void;
}) {
const { t } = useTranslation();
if (packing.status === 'error') {
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.packing.loadFailed')}
<button type="button" onClick={packing.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
}
if (!packing.link) return <UnlinkedCard onLink={canEdit ? packing.show : undefined} />;
if (!packing.packingEnabled) {
// Dimmed, not hidden: the tour still points at the list, it just cannot be shown.
return (
<div className="opacity-70">
<TourNotice icon={<PowerOff size={13} strokeWidth={2} />}>
<span className="font-semibold text-content-secondary">{linkName(packing.link, t)}</span>
<span className="block">{t('tours.packing.addonOff')}</span>
</TourNotice>
</div>
);
}
return <LinkedCard packing={packing} onOpenPacking={onOpenPacking} />;
}
/**
* P2D-XD-01 in a tour's detail: the packing list, or the category of it, the
* tour points at, read live from the trip's packing list as this user may see
* it. Who may change the tour links one, changes it or lets go through the
* dialog; the items themselves are only ever changed in Packing, which the
* card opens. Without Packing and without a link there is nothing to show.
*/
export function TourPackingSection({
tripId,
tour,
tourName,
canEdit,
onOpenPacking,
}: {
tripId: number;
tour: TourListItem;
tourName: string;
canEdit: boolean;
/** Opens the trip's packing list; absent where the detail cannot leave for it. */
onOpenPacking?: () => void;
}) {
const { t } = useTranslation();
const packing = useTourPackingLink({ tripId, tour });
if (packing.status === 'loading' || (!packing.packingEnabled && !packing.link)) return null;
const editable = canEdit && packing.status === 'ready';
let action: ReactNode = null;
if (editable && packing.link && packing.packingEnabled) {
action = (
<SectionPill onClick={packing.show} icon={<Link2 size={11} strokeWidth={2} />}>
{t('tours.packing.change')}
</SectionPill>
);
} else if (editable && packing.link) {
action = (
<Tooltip label={t('tours.packing.unlinkHint')}>
<span className="inline-flex">
<SectionPill onClick={() => void packing.unlink()} icon={<Unlink size={11} strokeWidth={2} />}>
{t('tours.packing.unlink')}
</SectionPill>
</span>
</Tooltip>
);
}
return (
<DialogSection className="flex-none" label={t('tours.packing.label')} action={action}>
<div data-testid="tour-packing-section">
<SectionBody packing={packing} canEdit={canEdit} onOpenPacking={onOpenPacking} />
</div>
<TourPackingLinkDialog packing={packing} tourName={tourName} />
</DialogSection>
);
}
@@ -0,0 +1,26 @@
import { Package } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { tintOf, WhiteBadge } from '../../Planner/planParts';
/** A packing category's own colour, as the packing list gives it, with a soft halo of the same colour. */
export function CategoryDot({ color }: { color: string }) {
return (
<span
aria-hidden="true"
className="h-2.5 w-2.5 flex-none rounded-full"
style={{ background: color, boxShadow: `0 0 0 3px ${tintOf(color, 24)}` }}
/>
);
}
/** How many items a list or category holds, as a badge; the count is read out in words. */
export function CountBadge({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span aria-label={t('tours.packing.items', { count })} className="inline-flex">
<WhiteBadge icon={<Package size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{count}
</WhiteBadge>
</span>
);
}
@@ -0,0 +1,215 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourListItem, TourPackingLink } from '@trek/shared';
import type { ReactNode } from 'react';
import { MemoryRouter } 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 { tourPackingLinkRepo } from '../../../repo/tourPackingLinkRepo';
import { useAddonStore } from '../../../store/addonStore';
import { useTripStore } from '../../../store/tripStore';
import type { PackingItem } from '../../../types';
import { TourPackingSection } from './TourPackingSection';
import { useTourPackingLink } from './useTourPackingLink';
// FE-TOUR-PACKING-001 to FE-TOUR-PACKING-010
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const TOUR = { place_id: 11, name: 'Ridge', tour_type: 'hike' } as TourListItem;
const GEAR: TourPackingLink = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
let nextId = 1;
function item(name: string, category: string | null, over: Partial<PackingItem> = {}): PackingItem {
return { id: nextId++, trip_id: 5, name, category, checked: 0, sort_order: nextId, quantity: 1, ...over } as PackingItem;
}
const ITEMS = [
item('Boots', 'Hiking gear', { checked: 1 }),
item('Rain jacket', 'Hiking gear', { quantity: 2 }),
item('Map', 'Hiking gear'),
item('Passport', 'Documents'),
item('...', 'Snacks'),
];
function wrapper({ children }: { children: ReactNode }) {
return (
<MemoryRouter>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
}
function setPacking(enabled: boolean) {
useAddonStore.setState({
addons: [
{ id: 'tours', enabled: true },
{ id: 'packing', enabled },
] as never,
});
}
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
setPacking(true);
useTripStore.setState({ packingItems: ITEMS });
vi.spyOn(tourPackingLinkRepo, 'read').mockResolvedValue(null);
});
afterEach(() => vi.restoreAllMocks());
describe('useTourPackingLink', () => {
it('FE-TOUR-PACKING-001: reads the link, shows its items live and offers every category with its colour', async () => {
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
const { result } = renderHook(() => useTourPackingLink({ tripId: 5, tour: TOUR }), { wrapper });
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(tourPackingLinkRepo.read).toHaveBeenCalledWith(5, 11, expect.any(AbortSignal));
expect(result.current.linked).toMatchObject({ state: 'ready', packed: 1 });
expect(result.current.linked?.items.map((entry) => entry.name)).toEqual(['Boots', 'Rain jacket', 'Map']);
expect(result.current.categories.map(({ name, count }) => [name, count])).toEqual([
['Hiking gear', 3],
['Documents', 1],
['Snacks', 0],
]);
expect(result.current.linkedColor).toBe(result.current.categories[0]?.color);
expect(result.current.wholeCount).toBe(4);
// Ticking an item off in Packing shows here at once.
act(() => useTripStore.setState({ packingItems: ITEMS.map((entry) => ({ ...entry, checked: 1 })) }));
expect(result.current.linked?.packed).toBe(3);
});
it('FE-TOUR-PACKING-002: the dialog starts on what the tour points at and links the choice only on confirm', async () => {
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
const link = vi.spyOn(tourPackingLinkRepo, 'link').mockResolvedValue({ category: 'Documents', linked_at: 'now' });
const { result } = renderHook(() => useTourPackingLink({ tripId: 5, tour: TOUR }), { wrapper });
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.show());
expect(result.current.open).toBe(true);
expect(result.current.choice).toBe('Hiking gear');
// The same choice again would change nothing.
expect(result.current.canConfirm).toBe(false);
act(() => result.current.choose('Documents'));
expect(result.current.canConfirm).toBe(true);
await act(() => result.current.confirm());
expect(link).toHaveBeenCalledWith(5, 11, { category: 'Documents' });
expect(result.current.link?.category).toBe('Documents');
expect(result.current.open).toBe(false);
expect(toast.success).toHaveBeenCalledWith('Packing list linked.');
});
it('FE-TOUR-PACKING-003: a refused write keeps the dialog and says why; letting go answers no link', async () => {
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
vi.spyOn(tourPackingLinkRepo, 'link').mockRejectedValue(new Error('boom'));
const unlink = vi.spyOn(tourPackingLinkRepo, 'unlink').mockResolvedValue(null);
const { result } = renderHook(() => useTourPackingLink({ tripId: 5, tour: TOUR }), { wrapper });
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.show());
act(() => result.current.choose(null));
await act(() => result.current.confirm());
expect(toast.error).toHaveBeenCalledWith('The link could not be saved.');
expect(result.current.open).toBe(true);
await act(() => result.current.unlink());
expect(unlink).toHaveBeenCalledWith(5, 11);
expect(result.current.link).toBeNull();
expect(toast.success).toHaveBeenCalledWith('Link removed. The packing list stays as it is.');
});
it('FE-TOUR-PACKING-004: a failed read says so and is read again on retry; a reloaded tour reads again', async () => {
vi.mocked(tourPackingLinkRepo.read).mockRejectedValueOnce(new Error('500'));
const { result, rerender } = renderHook(({ tour }) => useTourPackingLink({ tripId: 5, tour }), {
wrapper,
initialProps: { tour: TOUR },
});
await waitFor(() => expect(result.current.status).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(tourPackingLinkRepo.read).toHaveBeenCalledTimes(2);
rerender({ tour: { ...TOUR } });
await waitFor(() => expect(tourPackingLinkRepo.read).toHaveBeenCalledTimes(3));
});
});
describe('TourPackingSection', () => {
const section = (canEdit = true, onOpenPacking?: () => void) =>
render(
<TourPackingSection tripId={5} tour={TOUR} tourName="Ridge" canEdit={canEdit} onOpenPacking={onOpenPacking} />
);
it('FE-TOUR-PACKING-005: without a link it offers to link one, and links a category through the dialog', async () => {
const link = vi.spyOn(tourPackingLinkRepo, 'link').mockResolvedValue(GEAR);
section();
expect(await screen.findByText('No packing list linked')).toBeInTheDocument();
fireEvent.click(screen.getByText('Link a packing list'));
const whole = await screen.findByTestId('tour-packing-choice-whole');
expect(whole).toHaveAttribute('aria-checked', 'true');
expect(within(whole).getByText('4')).toBeInTheDocument();
expect(screen.queryByTestId('tour-packing-unlink')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-packing-choice-Hiking gear'));
fireEvent.click(screen.getByTestId('tour-packing-confirm'));
await waitFor(() => expect(link).toHaveBeenCalledWith(5, 11, { category: 'Hiking gear' }));
expect(await screen.findByTestId('tour-packing-card')).toBeInTheDocument();
});
it('FE-TOUR-PACKING-006: a linked category shows its items, how many are packed, and opens Packing', async () => {
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
const open = vi.fn();
section(true, open);
const card = await screen.findByTestId('tour-packing-card');
expect(within(card).getByText('Hiking gear')).toBeInTheDocument();
expect(within(card).getByText('1 of 3 packed')).toBeInTheDocument();
expect(within(card).getByText('Rain jacket')).toBeInTheDocument();
expect(within(card).getByText('×2')).toBeInTheDocument();
expect(within(card).queryByText('Passport')).not.toBeInTheDocument();
fireEvent.click(within(card).getByText('Open packing list'));
expect(open).toHaveBeenCalledTimes(1);
expect(screen.getByText('Change')).toBeInTheDocument();
});
it('FE-TOUR-PACKING-007: a long list shows its first items and how many more; all packed says so', async () => {
const many = Array.from({ length: 8 }, (_, index) => item(`Thing ${index + 1}`, 'Bulk', { checked: 1 }));
useTripStore.setState({ packingItems: many });
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue({ category: null, linked_at: 'x' });
section(false);
const card = await screen.findByTestId('tour-packing-card');
expect(within(card).getByText('Whole packing list')).toBeInTheDocument();
expect(within(card).getByText('+3 more')).toBeInTheDocument();
expect(within(card).getByText('All packed')).toBeInTheDocument();
expect(within(card).queryByText('Open packing list')).not.toBeInTheDocument();
// Read only: no way to change the link.
expect(screen.queryByText('Change')).not.toBeInTheDocument();
});
it('FE-TOUR-PACKING-008: a category with no item to see reads as missing, an empty one as empty', async () => {
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue({ category: 'Climbing', linked_at: 'x' });
const { unmount } = section();
expect(await screen.findByText('“Climbing” has no items you can see any more.')).toBeInTheDocument();
unmount();
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue({ category: 'Snacks', linked_at: 'x' });
section();
expect(await screen.findByText('“Snacks” has no items yet.')).toBeInTheDocument();
});
it('FE-TOUR-PACKING-009: with Packing off a link is shown dimmed and can be let go, and without one nothing shows', async () => {
setPacking(false);
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
const unlink = vi.spyOn(tourPackingLinkRepo, 'unlink').mockResolvedValue(null);
section();
expect(await screen.findByText('Packing lists are switched off, so the list cannot be shown.')).toBeInTheDocument();
fireEvent.click(screen.getByText('Remove link'));
await waitFor(() => expect(unlink).toHaveBeenCalledWith(5, 11));
await waitFor(() => expect(screen.queryByTestId('tour-packing-section')).not.toBeInTheDocument());
});
it('FE-TOUR-PACKING-010: a failed read is said, with a retry', async () => {
vi.mocked(tourPackingLinkRepo.read).mockRejectedValueOnce(new Error('500'));
section();
expect(await screen.findByText('The packing link could not be loaded.')).toBeInTheDocument();
fireEvent.click(screen.getByText('Retry'));
expect(await screen.findByText('No packing list linked')).toBeInTheDocument();
});
});
@@ -0,0 +1,137 @@
import {
linkedPackingItems,
packingCategoriesOf,
type LinkedPackingItems,
type PackingCategoryChoice,
type TourListItem,
type TourPackingLink,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { translateApiError, useTranslation } from '../../../i18n';
import { tourPackingLinkRepo } from '../../../repo/tourPackingLinkRepo';
import { useAddonStore } from '../../../store/addonStore';
import { useTripStore } from '../../../store/tripStore';
import type { PackingItem } from '../../../types';
import { packingCategoryOrder, packingViewItems } from '../../Packing/packingListModel';
import { katColor } from '../../Packing/packingListPanel.helpers';
import { useToast } from '../../shared/Toast';
/** The link as read for one tour, so a newer tour never shows an older tour's answer. */
interface LinkAnswer {
key: string;
status: 'ready' | 'error';
link: TourPackingLink | null;
}
/** What the section shows: nothing known yet, the read failed, or the link as it reads now. */
type TourPackingStatus = 'loading' | 'error' | 'ready';
/** A category the dialog offers, with the colour the packing list gives it. */
interface TourPackingCategory extends PackingCategoryChoice {
color: string;
}
/**
* P2D-XD-01: the packing list a tour points at, for its detail. The link is
* read through the offline core whenever the tour reloads (another traveller
* may have changed it); the items come live from the trip's packing list as
* this user may see it, so ticking an item off in Packing shows here at once.
* The dialog chooses what to point at and writes only on its confirm; letting
* go writes nothing to the packing list either.
*/
export function useTourPackingLink({ tripId, tour }: { tripId: number; tour: TourListItem }) {
const { t } = useTranslation();
const toast = useToast();
const packingEnabled = useAddonStore((state) => state.isEnabled('packing'));
const items = useTripStore((state) => state.packingItems);
const placeId = tour.place_id;
const key = `${tripId}:${placeId}`;
const [answer, setAnswer] = useState<LinkAnswer | null>(null);
const [reloads, setReloads] = useState(0);
const [open, setOpen] = useState(false);
const [choice, setChoice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
const controller = new AbortController();
tourPackingLinkRepo.read(tripId, placeId, controller.signal).then(
(link) => {
if (!controller.signal.aborted) setAnswer({ key, status: 'ready', link });
},
() => {
if (!controller.signal.aborted) setAnswer({ key, status: 'error', link: null });
}
);
return () => controller.abort();
// `tour` is replaced when the trip's tours reload, which is when the link may have changed elsewhere.
}, [key, tripId, placeId, tour, reloads]);
const current = answer?.key === key ? answer : null;
const status: TourPackingStatus = current?.status ?? 'loading';
const link = current?.link ?? null;
const linked = useMemo<LinkedPackingItems<PackingItem> | null>(
() => (link ? linkedPackingItems(link, items) : null),
[link, items]
);
const defaultCategory = t('packing.defaultCategory');
const categories = useMemo<TourPackingCategory[]>(() => {
// The colours of the shared list, as the packing list shows it first.
const order = packingCategoryOrder(packingViewItems(items, 'common'), defaultCategory);
return packingCategoriesOf(items).map((category) => ({ ...category, color: katColor(category.name, order) }));
}, [items, defaultCategory]);
const wholeCount = useMemo(() => linkedPackingItems({ category: null }, items).items.length, [items]);
const linkedColor = link?.category ? (categories.find((c) => c.name === link.category)?.color ?? null) : null;
const show = useCallback(() => {
setChoice(link?.category ?? null);
setOpen(true);
}, [link]);
const close = useCallback(() => {
if (!saving) setOpen(false);
}, [saving]);
const retry = useCallback(() => setReloads((count) => count + 1), []);
const settle = async (write: () => Promise<TourPackingLink | null>, done: string) => {
if (saving) return;
setSaving(true);
try {
const next = await write();
setAnswer({ key, status: 'ready', link: next });
setOpen(false);
toast.success(done);
} catch (err: unknown) {
toast.error(translateApiError(t, err, 'tours.packing.failed'));
} finally {
setSaving(false);
}
};
const confirm = () =>
settle(() => tourPackingLinkRepo.link(tripId, placeId, { category: choice }), t('tours.packing.linked'));
const unlink = () => settle(() => tourPackingLinkRepo.unlink(tripId, placeId), t('tours.packing.unlinked'));
/** The choice already is what the tour points at, so linking again would change nothing. */
const unchanged = link !== null && link.category === choice;
return {
packingEnabled,
status,
link,
linked,
linkedColor,
categories,
wholeCount,
open,
choice,
saving,
canConfirm: !saving && !unchanged,
show,
close,
choose: setChoice,
confirm,
unlink,
retry,
};
}
export type TourPackingLinkController = ReturnType<typeof useTourPackingLink>;
+9
View File
@@ -90,4 +90,13 @@ describe('cacheTourAnswer', () => {
const row = await cacheTourAnswer(3, { tour: tour({ tour_type: 'hike' }), waypoints });
expect(row).not.toHaveProperty('obstacle_check');
});
it('CT-004: a packing link answer is kept, null lets it go, and an answer without one keeps the cached one', async () => {
const link = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
expect(await cacheTourAnswer(3, { tour: tour(), waypoints, packing_link: link })).toMatchObject({ packing_link: link });
expect(await cacheTourAnswer(3, { tour: tour({ name: 'Renamed' }), waypoints })).toMatchObject({ packing_link: link });
expect((await cacheTourAnswer(3, { tour: tour(), waypoints, packing_link: null })).packing_link).toBeNull();
const fresh = await cacheTourAnswer(4, { tour: tour({ place_id: 41 }), waypoints });
expect(fresh).not.toHaveProperty('packing_link');
});
});
+7 -4
View File
@@ -3,10 +3,10 @@ import { offlineDb, type CachedTour } from './offlineDb';
/**
* A tour as the server answered it, written to the cache with its control
* points. Where it came from, a paddle tour's checklist and its recorded
* tracks (ACT-01) are kept from the row already there when the answer carries
* none, so a write answered without them does not lose them. Safe inside a
* Dexie transaction that holds the tours table.
* points. Where it came from, a paddle tour's checklist, its recorded tracks
* (ACT-01) and its packing link are kept from the row already there when the
* answer carries none, so a write answered without them does not lose them.
* Safe inside a Dexie transaction that holds the tours table.
*/
export async function cacheTourAnswer(tripId: number, answer: TourCreateResponse): Promise<CachedTour> {
const previous = await offlineDb.tours.get(answer.tour.place_id);
@@ -14,6 +14,8 @@ export async function cacheTourAnswer(tripId: number, answer: TourCreateResponse
// Only a paddle tour has a checklist; a tour that stopped being one drops it.
const check = answer.tour.tour_type === 'kayak' ? (answer.obstacle_check ?? previous?.obstacle_check) : undefined;
const activities = answer.activities ?? previous?.activities;
// An answer that says nothing about the packing link keeps the one known here; null is a link let go.
const packingLink = answer.packing_link !== undefined ? answer.packing_link : previous?.packing_link;
const row: CachedTour = {
...answer.tour,
trip_id: tripId,
@@ -21,6 +23,7 @@ export async function cacheTourAnswer(tripId: number, answer: TourCreateResponse
...(provenance ? { provenance } : {}),
...(check ? { obstacle_check: check } : {}),
...(activities ? { activities } : {}),
...(packingLink !== undefined ? { packing_link: packingLink } : {}),
};
await offlineDb.tours.put(row);
return row;
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Ferrata, RoadtripPreferences, TourActivity, TourListItem, TourObstacleCheck, TourProvenance, TourVisit, TourWaypoint } from '@trek/shared';
import type { Ferrata, RoadtripPreferences, TourActivity, TourListItem, TourObstacleCheck, TourPackingLink, TourProvenance, TourVisit, TourWaypoint } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -21,6 +21,8 @@ export interface CachedTour extends TourListItem {
obstacle_check?: TourObstacleCheck;
/** ACT-01: the recorded tracks its detail last answered, kept while the list names the same ones. */
activities?: TourActivity[];
/** P2D-XD-01: the packing list or category the tour points at as last read, null for none; absent while never read. */
packing_link?: TourPackingLink | null;
}
/**
@@ -500,6 +502,8 @@ export async function replaceTripTours(tripId: number, tours: TourListItem[]): P
...(previous?.provenance ? { provenance: previous.provenance } : {}),
...(previous?.obstacle_check ? { obstacle_check: previous.obstacle_check } : {}),
...keptActivities(previous, tour),
// The list carries no packing link (P2D-XD-01); the one last read stays.
...(previous?.packing_link !== undefined ? { packing_link: previous.packing_link } : {}),
};
}));
});
@@ -0,0 +1,17 @@
// P2D-XD-01: from a tour's packing link to the trip's Lists tab, the tour's detail closed on the way.
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useTourDetailActions } from './useTourDetailActions';
import type { useTripPlanner } from './useTripPlanner';
describe('useTourDetailActions', () => {
it('FE-TOUR-DETAIL-ACTIONS-001: opening the packing list closes the tour and switches to the Lists tab', () => {
const planner = { setSelectedPlaceId: vi.fn(), handleTabChange: vi.fn() };
const { result } = renderHook(() =>
useTourDetailActions(planner as unknown as ReturnType<typeof useTripPlanner>)
);
result.current.onOpenPacking();
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null);
expect(planner.handleTabChange).toHaveBeenCalledWith('listen');
});
});
@@ -36,6 +36,11 @@ export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>)
onToursChanged: (placeIds: number[]) => invalidateTourPlaceIds({ placeIds }),
/** LIB-02: a tour taken over from another trip; the tours rail opens it in the planner. */
onTakenOver: bringIn,
/** P2D-XD-01: the packing list a tour points at, in the trip's Lists tab. */
onOpenPacking: () => {
planner.setSelectedPlaceId(null);
planner.handleTabChange('listen');
},
onSaveAsNew: async (tour: TourListItem) => {
try {
const name = t('tours.copy.name', { name: tour.name }).slice(0, 255);
+154
View File
@@ -0,0 +1,154 @@
// P2D-XD-01: a tour's packing link through the offline core. Online the server
// answers and the answer is kept on the cached tour; offline, for a tour not yet
// synced, or behind a queued write of the tour, the link waits in the queue.
import type { TourDetailResponse, TourListItem, TourPackingLink } from '@trek/shared';
import 'fake-indexeddb/auto';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { clearAll, offlineDb, replaceTripTours } from '../db/offlineDb';
import { mutationQueue } from '../sync/mutationQueue';
import { tourPackingLinkRepo } from './tourPackingLinkRepo';
function setOnline(value: boolean): void {
Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true });
}
function tour(overrides: Partial<TourListItem> = {}): TourListItem {
return {
place_id: 40,
name: 'Ridge walk',
tour_type: 'hike',
distance: 8,
elevation_gain: 600,
elevation_loss: 600,
duration: 180,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
...overrides,
};
}
const gear: TourPackingLink = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
const answer = (packing_link: TourPackingLink | null): TourDetailResponse => ({ tour: tour(), waypoints: [], packing_link });
const cachedLink = async (placeId = 40) => (await offlineDb.tours.get(placeId))?.packing_link;
beforeEach(async () => {
await clearAll();
setOnline(true);
await offlineDb.tours.put({ ...tour(), trip_id: 3 });
});
afterEach(() => {
vi.restoreAllMocks();
setOnline(true);
});
describe('tourPackingLinkRepo online', () => {
it('FE-REPO-TPL-001: read asks the server and keeps the link on the cached tour, through a list refresh too', async () => {
server.use(http.get('/api/trips/3/tours/40/packing-link', () => HttpResponse.json({ packing_link: gear })));
await expect(tourPackingLinkRepo.read(3, 40)).resolves.toEqual(gear);
expect(await cachedLink()).toEqual(gear);
// The list carries no link; the one read stays.
await replaceTripTours(3, [tour({ name: 'Ridge walk, renamed' })]);
expect(await cachedLink()).toEqual(gear);
});
it('FE-REPO-TPL-002: link and unlink go straight to the server, and its answer is cached', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/trips/3/tours/40/packing-link', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(answer(gear));
}),
http.delete('/api/trips/3/tours/40/packing-link', () => HttpResponse.json(answer(null)))
);
await expect(tourPackingLinkRepo.link(3, 40, { category: 'Hiking gear' })).resolves.toEqual(gear);
expect(bodies).toEqual([{ category: 'Hiking gear' }]);
expect(await cachedLink()).toEqual(gear);
await expect(tourPackingLinkRepo.unlink(3, 40)).resolves.toBeNull();
expect(await cachedLink()).toBeNull();
expect(await offlineDb.mutationQueue.count()).toBe(0);
});
it('FE-REPO-TPL-003: a refused link throws and keeps what this device had', async () => {
await offlineDb.tours.update(40, { packing_link: gear });
server.use(
http.put('/api/trips/3/tours/40/packing-link', () =>
HttpResponse.json({ error: 'Packing category not found' }, { status: 400 })
)
);
await expect(tourPackingLinkRepo.link(3, 40, { category: 'Climbing' })).rejects.toMatchObject({
response: { status: 400 },
});
expect(await cachedLink()).toEqual(gear);
});
it('FE-REPO-TPL-004: a lost connection reads the link this device last had, and an unknown one stays unknown', async () => {
server.use(http.get('/api/trips/3/tours/:placeId/packing-link', () => HttpResponse.error()));
await expect(tourPackingLinkRepo.read(3, 40)).rejects.toThrow('Packing link not available offline');
await offlineDb.tours.update(40, { packing_link: null });
await expect(tourPackingLinkRepo.read(3, 40)).resolves.toBeNull();
await offlineDb.tours.update(40, { packing_link: gear });
await expect(tourPackingLinkRepo.read(3, 40)).resolves.toEqual(gear);
});
it('FE-REPO-TPL-005: an aborted read stays aborted instead of reading the cache', async () => {
server.use(http.get('/api/trips/3/tours/40/packing-link', () => HttpResponse.error()));
const controller = new AbortController();
controller.abort();
await expect(tourPackingLinkRepo.read(3, 40, controller.signal)).rejects.toBeDefined();
});
});
describe('tourPackingLinkRepo offline', () => {
it('FE-REPO-TPL-006: offline a link is on the tour at once and waits in the queue as a write of its tour', async () => {
setOnline(false);
const send = vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
const local = await tourPackingLinkRepo.link(3, 40, { category: null });
expect(local).toEqual({ category: null, linked_at: expect.stringMatching(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/) });
expect(await cachedLink()).toEqual(local);
await expect(tourPackingLinkRepo.read(3, 40)).resolves.toEqual(local);
await expect(tourPackingLinkRepo.unlink(3, 40)).resolves.toBeNull();
const queued = await offlineDb.mutationQueue.orderBy('createdAt').toArray();
expect(queued.map((m) => [m.method, m.url, m.body, m.resource, m.entityId])).toEqual([
['PUT', '/trips/3/tours/40/packing-link', { category: null }, 'tours', 40],
['DELETE', '/trips/3/tours/40/packing-link', undefined, 'tours', 40],
]);
expect(send).toHaveBeenCalledTimes(2);
});
it('FE-REPO-TPL-007: a tour written here offline is linked through the queue, its id filled in when it syncs', async () => {
await offlineDb.tours.put({ ...tour({ place_id: -5 }), trip_id: 3 });
vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
await tourPackingLinkRepo.link(3, -5, { category: 'Hiking gear' });
const [queued] = await offlineDb.mutationQueue.toArray();
expect(queued).toMatchObject({ url: '/trips/3/tours/{id}/packing-link', entityId: -5, tempEntityId: -5 });
await expect(tourPackingLinkRepo.read(3, -5)).resolves.toMatchObject({ category: 'Hiking gear' });
});
it('FE-REPO-TPL-008: online, behind an older queued write of the tour, the link queues and reads as queued', async () => {
vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
vi.spyOn(mutationQueue, 'mustQueue').mockResolvedValue(true);
const put = vi.fn();
server.use(http.put('/api/trips/3/tours/40/packing-link', put));
await tourPackingLinkRepo.link(3, 40, { category: 'Hiking gear' });
expect(put).not.toHaveBeenCalled();
expect(await offlineDb.mutationQueue.count()).toBe(1);
await expect(tourPackingLinkRepo.read(3, 40)).resolves.toMatchObject({ category: 'Hiking gear' });
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { TourDetailResponse, TourPackingLink, TourPackingLinkRequest } from '@trek/shared';
import { tourPackingLinkApi, tourPackingLinkPath } from '../api/tourPackingLink';
import { cacheTourAnswer } from '../db/cacheTour';
import { offlineDb } from '../db/offlineDb';
import { generateUUID, mutationQueue } from '../sync/mutationQueue';
import { onlineThenCache } from './withOfflineFallback';
/** A link write is a write of its tour: it queues behind the tour's own writes and replays like them. */
const RESOURCE = 'tours';
/** The link as this device last read it: null for none, undefined while it never read one. */
async function cachedLink(placeId: number): Promise<TourPackingLink | null | undefined> {
return (await offlineDb.tours.get(placeId))?.packing_link;
}
/** Keeps the link on the cached tour, where this device holds that tour. */
async function keepLink(placeId: number, link: TourPackingLink | null): Promise<void> {
await offlineDb.tours.update(placeId, { packing_link: link });
}
/** Now, in the stored spelling the server's own link time has. */
function storedNow(): string {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
/**
* A link write. Offline, for a tour that has not synced yet, or behind an
* older write of the same tour still queued, the cached tour takes the link
* at once and the write waits in the queue; otherwise it goes straight to the
* server and its answer is cached like any tour write.
*/
async function write(
tripId: number | string,
placeId: number,
request: { method: 'PUT'; body: TourPackingLinkRequest } | { method: 'DELETE' },
local: TourPackingLink | null
): Promise<TourPackingLink | null> {
const unsynced = placeId < 0;
if (unsynced || (await mutationQueue.mustQueue(RESOURCE, placeId))) {
await keepLink(placeId, local);
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: request.method,
url: tourPackingLinkPath(tripId, unsynced ? '{id}' : placeId),
body: request.method === 'PUT' ? request.body : undefined,
resource: RESOURCE,
entityId: placeId,
...(unsynced ? { tempEntityId: placeId } : {}),
});
mutationQueue.sendSoon();
return local;
}
const answer: TourDetailResponse =
request.method === 'PUT'
? await tourPackingLinkApi.link(tripId, placeId, request.body)
: await tourPackingLinkApi.unlink(tripId, placeId);
await cacheTourAnswer(Number(tripId), answer);
return answer.packing_link ?? null;
}
/**
* P2D-XD-01 through the offline core. The link is kept on the cached tour, so
* it reads offline as last read, and a link made or let go offline waits in the
* queue as a write of its tour. The items it shows are not kept here: they
* come from the trip's packing list, which has an offline copy of its own.
*/
export const tourPackingLinkRepo = {
async read(tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLink | null> {
const known = await cachedLink(placeId);
// A tour written here offline has no link but the one made here.
if (placeId < 0) return known ?? null;
// Behind a queued write of the tour (offline, every write is), this device's copy is the newer one.
if (known !== undefined && (await mutationQueue.mustQueue(RESOURCE, placeId))) return known;
return onlineThenCache(
async () => {
const { packing_link } = await tourPackingLinkApi.read(tripId, placeId, signal);
await keepLink(placeId, packing_link);
return packing_link;
},
async () => {
// An aborted request is not a lost connection: the caller moved on.
signal?.throwIfAborted();
// Never read here: whether the tour points anywhere is unknown, not "nowhere".
if (known === undefined) throw new Error('Packing link not available offline');
return known;
}
);
},
/** Points the tour at the whole list (category null) or at one category. */
async link(tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourPackingLink | null> {
return write(tripId, placeId, { method: 'PUT', body }, { category: body.category, linked_at: storedNow() });
},
/** Lets go of what the tour points at; the packing list stays as it is. */
async unlink(tripId: number | string, placeId: number): Promise<TourPackingLink | null> {
return write(tripId, placeId, { method: 'DELETE' }, null);
},
};
+1 -1
View File
@@ -17,7 +17,7 @@ import { onlineThenCache } from './withOfflineFallback'
import type { Place } from '../types'
/** The list item a cached row stands for: the cache-only fields stripped. */
function toListItem({ trip_id: _tripId, waypoints: _waypoints, provenance: _provenance, obstacle_check: _check, activities: _activities, ...tour }: CachedTour): TourListItem {
function toListItem({ trip_id: _tripId, waypoints: _waypoints, provenance: _provenance, obstacle_check: _check, activities: _activities, packing_link: _packingLink, ...tour }: CachedTour): TourListItem {
return tour
}
+5 -1
View File
@@ -1,4 +1,4 @@
import { tourVisitListResponseSchema } from '@trek/shared';
import { tourPackingLinkResponseSchema, tourVisitListResponseSchema } from '@trek/shared';
import { contractHandler } from '../contract';
export const toursHandlers = [
@@ -6,4 +6,8 @@ export const toursHandlers = [
contractHandler('get', '/api/trips/:id/tour-visits', { response: tourVisitListResponseSchema }, () => ({
visits: [],
})),
// P2D-XD-01: a tour's detail reads its packing link; a tour points at none unless a test says so.
contractHandler('get', '/api/trips/:id/tours/:placeId/packing-link', { response: tourPackingLinkResponseSchema }, () => ({
packing_link: null,
})),
];
+1
View File
@@ -114,6 +114,7 @@
"TourChangeSteps": "tours",
"TourObstacleReviews": "tours",
"TourStationVisits": "tours",
"TourPackingLinks": "tours",
"TourSources": "tours",
"TourTypes": "tours",
"TourWaypoints": "tours",
@@ -0,0 +1,29 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourPackingLinksRepository } from '../repositories/TourPackingLinks.repository';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
export class TourPackingLinks {
[EntityRepositoryType]?: TourPackingLinksRepository;
[PrimaryKeyProp]?: 'place';
place?: Ref<Tours> | null;
place_id?: number | null;
category?: string | null;
linked_by?: number | null;
linked_at!: string & Opt;
linkedByRef?: Ref<Users> | null;
}
export const TourPackingLinksSchema = defineEntity({
class: TourPackingLinks,
repository: () => TourPackingLinksRepository,
properties: {
place: () => p.oneToOne(Tours).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
category: p.text().nullable(),
linked_by: p.integer().nullable().persist(false).index('idx_tour_packing_links_linked_by'),
linked_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_tour_packing_links_linked_by'),
},
});
+3
View File
@@ -6,6 +6,7 @@ import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourChangeSteps } from './TourChangeSteps.entity';
import { TourObstacleReviews } from './TourObstacleReviews.entity';
import { TourPackingLinks } from './TourPackingLinks.entity';
import { TourSources } from './TourSources.entity';
import { TourTypes } from './TourTypes.entity';
import { TourWaypoints } from './TourWaypoints.entity';
@@ -56,6 +57,7 @@ export class Tours {
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_change_steps_collection = new Collection<TourChangeSteps>(this);
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_packing_links: Ref<TourPackingLinks> | null = null;
tour_sources: Ref<TourSources> | null = null;
tour_waypoints_collection = new Collection<TourWaypoints>(this);
}
@@ -192,6 +194,7 @@ export const ToursSchema = defineEntity({
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('place').hidden(),
tour_change_steps_collection: () => p.oneToMany(TourChangeSteps).mappedBy('place').hidden(),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_packing_links: () => p.oneToOne(TourPackingLinks).ref().mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
},
+3
View File
@@ -54,6 +54,7 @@ import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity';
import { TodoItems } from './TodoItems.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourPackingLinks } from './TourPackingLinks.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
@@ -184,6 +185,7 @@ export class Users {
todo_items_collection = new Collection<TodoItems>(this);
tour_activities_collection = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_packing_links_collection = new Collection<TourPackingLinks>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
@@ -332,6 +334,7 @@ export const UsersSchema = defineEntity({
todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(),
tour_packing_links_collection: () => p.oneToMany(TourPackingLinks).mappedBy('linkedByRef').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
+2
View File
@@ -112,6 +112,7 @@ import { TourActivitiesSchema } from './TourActivities.entity';
import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity';
import { TourChangeStepsSchema } from './TourChangeSteps.entity';
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
import { TourPackingLinksSchema } from './TourPackingLinks.entity';
import { TourSourcesSchema } from './TourSources.entity';
import { TourStationVisitsSchema } from './TourStationVisits.entity';
import { TourTypesSchema } from './TourTypes.entity';
@@ -261,6 +262,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TourActivityExclusionsSchema,
TourChangeStepsSchema,
TourObstacleReviewsSchema,
TourPackingLinksSchema,
TourSourcesSchema,
TourStationVisitsSchema,
TourTypesSchema,
@@ -0,0 +1,10 @@
// 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 TourPackingLinksTable {
place_id: InsertOptional<number>;
category: InsertOptional<string | null>;
linked_by: InsertOptional<number | null>;
linked_at: InsertOptional<string>;
}
+2
View File
@@ -118,6 +118,7 @@ import type { TourActivitiesTable } from './TourActivities.table';
import type { TourActivityExclusionsTable } from './TourActivityExclusions.table';
import type { TourChangeStepsTable } from './TourChangeSteps.table';
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
import type { TourPackingLinksTable } from './TourPackingLinks.table';
import type { TourSourcesTable } from './TourSources.table';
import type { TourStationVisitsTable } from './TourStationVisits.table';
import type { TourTypesTable } from './TourTypes.table';
@@ -273,6 +274,7 @@ export interface DB {
tour_activity_exclusions: TourActivityExclusionsTable;
tour_change_steps: TourChangeStepsTable;
tour_obstacle_reviews: TourObstacleReviewsTable;
tour_packing_links: TourPackingLinksTable;
tour_sources: TourSourcesTable;
tour_station_visits: TourStationVisitsTable;
tour_types: TourTypesTable;
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* P2D-XD-01: a tour points at the packing list of its trip, as a whole or at
* one of its categories. The row only names what it points at: `category` is
* NULL for the whole list, otherwise the category as its items carry it. The
* items stay Packing's, so nothing here copies, owns or deletes one, and
* removing the row leaves the list as it was. One link per tour, gone with
* the tour; `linked_by` turns unknown when that account goes.
*/
export class Migration20200101046110_a_tour_points_at_its_packing_list extends Migration {
override name = 'Migration20200101046110_a_tour_points_at_its_packing_list';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_packing_links (
place_id INTEGER PRIMARY KEY REFERENCES tours(place_id) ON DELETE CASCADE,
category TEXT,
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_packing_links_linked_by ON tour_packing_links(linked_by)');
}
}
@@ -0,0 +1,55 @@
import { currentTimestampKysely } from '../dialect/kysely-functions';
import type { TourPackingLinks } from '../entities/TourPackingLinks.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type LinksDB = Pick<DB, 'tour_packing_links'>;
/** What a tour points at in its trip's packing list: null for the whole list, otherwise one category. */
interface TourPackingLinkRow {
category: string | null;
linked_at: string;
}
/**
* `tour_packing_links` (P2D-XD-01): the packing list, or the category of it,
* a tour points at. One row per tour, gone with the tour. It names what it
* points at and nothing else, so no statement here reaches a packing item.
*/
export class TourPackingLinksRepository extends TrekRepository<TourPackingLinks> {
private db() {
return this.kysely<LinksDB>();
}
/** The tour's link; undefined while it points at nothing. */
async findForTour(place_id: number): Promise<TourPackingLinkRow | undefined> {
return await this.db()
.selectFrom('tour_packing_links')
.select(['category', 'linked_at'])
.where('place_id', '=', place_id)
.executeTakeFirst();
}
/** Points the tour at the list or a category, replacing what it pointed at before; the time is the new link's. */
async put(place_id: number, category: string | null, linked_by: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.db()
.insertInto('tour_packing_links')
.values({ place_id, category, linked_by })
.onConflict((oc) =>
oc.column('place_id').doUpdateSet((eb) => ({
category: eb.ref('excluded.category'),
linked_by: eb.ref('excluded.linked_by'),
// The clock in the spelling of the live database: CURRENT_TIMESTAMP is no function call.
linked_at: currentTimestampKysely(platform),
})),
)
.execute();
}
/** Lets go of what the tour points at; false when it pointed at nothing. */
async remove(place_id: number): Promise<boolean> {
const result = await this.db().deleteFrom('tour_packing_links').where('place_id', '=', place_id).executeTakeFirst();
return Number(result.numDeletedRows ?? 0) > 0;
}
}
@@ -215,6 +215,7 @@ type ToursKyselyDB = Pick<
| 'tour_sources'
| 'days'
| 'tour_obstacle_reviews'
| 'tour_packing_links'
| 'trips'
>;
@@ -569,6 +570,35 @@ export class ToursRepository extends TrekRepository<Tours> {
.execute();
}
/**
* P2D-XD-01: the packing list or category a copied tour pointed at, onto its
* copy in the copied trip, whose packing list is copied too. Nothing for a
* tour that pointed at nothing.
*/
async copyPackingLink(source_place_id: number, place_id: number): Promise<void> {
await this.db()
.insertInto('tour_packing_links')
.columns(['place_id', 'category', 'linked_by', 'linked_at'])
.expression((eb) =>
eb
.selectFrom('tour_packing_links')
.select((inner) => [inner.val(place_id).as('place_id'), 'category', 'linked_by', 'linked_at'])
.where('place_id', '=', source_place_id),
)
.execute();
}
/**
* What a copied trip's tour keeps besides its facet and its points: its
* import record, its obstacle decisions and its packing link, onto its copy.
* Called inside the trip copy's transaction.
*/
async copyTourRelations(source_place_id: number, place_id: number): Promise<void> {
await this.copySource(source_place_id, place_id);
await this.copyObstacleReviews(source_place_id, place_id);
await this.copyPackingLink(source_place_id, place_id);
}
/** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
return await this.db()
@@ -0,0 +1,5 @@
import { tourPackingLinkRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourPackingLinkDto extends createZodDto(tourPackingLinkRequestSchema) {}
@@ -0,0 +1,56 @@
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 { ResponseContract } from '../common/response-contract';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { TourPackingLinkDto } from './dto/tour-packing-link.dto';
import { TourPackingLinkService } from './tour-packing-link.service';
import { Body, Controller, Delete, Get, Headers, Param, Put, UseGuards } from '@nestjs/common';
import { tourDetailResponseSchema, tourPackingLinkResponseSchema } from '@trek/shared';
/**
* P2D-XD-01 /api/trips/:tripId/tours/:placeId/packing-link: the packing list,
* or the category of it, a tour points at. Reading it needs the trip; pointing
* the tour somewhere else or letting go needs 'place_edit', as every other
* change of a tour does. The writes answer the tour with its link, so a write
* replayed from the offline queue lands like any other tour write.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourPackingLinkController {
constructor(private readonly packingLinks: TourPackingLinkService) {}
@Get(':placeId/packing-link')
@ResponseContract(tourPackingLinkResponseSchema)
async read(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
return await this.packingLinks.read(tripId, placeId);
}
@RequirePermission('place_edit')
@Put(':placeId/packing-link')
@ResponseContract(tourDetailResponseSchema)
async link(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Body() body: TourPackingLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.packingLinks.link(tripId, placeId, body, user.id, socketId);
}
@RequirePermission('place_edit')
@Delete(':placeId/packing-link')
@ResponseContract(tourDetailResponseSchema)
async unlink(
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Headers('x-socket-id') socketId?: string,
) {
return await this.packingLinks.unlink(tripId, placeId, socketId);
}
}
@@ -0,0 +1,193 @@
import { ADDON_IDS } from '../../addons';
import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity';
import { Tours } from '../../db/entities/Tours.entity';
import type { TourPackingLinksRepository } from '../../db/repositories/TourPackingLinks.repository';
import type { TourListRow, ToursRepository } from '../../db/repositories/Tours.repository';
import { AddonsService } from '../addons/addons.service';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { PackingService } from '../packing/packing.service';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
linkedPackingItems,
type TourDetailResponse,
type TourPackingLink,
type TourPackingLinkRequest,
type TourPackingLinkResponse,
type TourPackingLinkState,
} from '@trek/shared';
/** A packing item as the viewer may see it, with what a link shows of it. */
interface VisiblePackingItem {
id: number;
name: string;
category: string | null;
checked: number | null;
quantity: number;
}
/** One item of a linked list as an assistant reads it. */
interface TourPackingViewItem {
id: number;
name: string;
category: string | null;
quantity: number;
packed: boolean;
}
/**
* P2D-XD-01: a tour's packing link as an assistant reads it: what it points
* at, the items of it the user may see with how many are packed, and the
* tour's own facts as context for a suggestion. Unknown facts stay null.
*/
interface TourPackingView {
packing_link: TourPackingLink | null;
/** How the link reads; null while the tour points at nothing. */
state: TourPackingLinkState | null;
items: TourPackingViewItem[];
packed: number;
total: number;
tour: {
name: string;
tour_type: string;
distance_km: number | null;
elevation_gain_m: number | null;
elevation_loss_m: number | null;
walking_minutes: number | null;
planned_duration_minutes: number | null;
difficulty: string | null;
max_hiking_difficulty: number | null;
};
}
/** A saved tour of the trip, by the ids a route or a tool names it with. */
interface TourInTrip {
tripId: number;
placeId: number;
tour: TourListRow;
}
/**
* P2D-XD-01: a tour points at the packing list of its trip, as a whole or at
* one of its categories. Packing keeps the list, its items and who sees which:
* the link is read against the items the user may see, and nothing here
* copies, changes or deletes one. Linking needs the Packing addon on and, for a
* category, that the user can see an item of it; removing a link leaves the
* list as it was. REST and MCP call the same methods.
*/
@Injectable()
export class TourPackingLinkService {
constructor(
@InjectRepository(TourPackingLinks) private readonly links: TourPackingLinksRepository,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
private readonly tours: ToursService,
private readonly packing: PackingService,
private readonly addons: AddonsService,
) {}
/** The saved tour of this trip; 404 for a tour of another trip, a plain place or a malformed id. */
private async tourIn(tripId: number | string, placeId: number | string): Promise<TourInTrip> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);
const tour = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined;
if (!tour || tid === null || pid === null) throw new DomainError(404, 'Tour not found');
return { tripId: tid, placeId: pid, tour };
}
/** Packing has to be on for a link to be made or read through. */
private async requirePacking(): Promise<void> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.PACKING))) {
throw new DomainError(404, 'Packing addon is not enabled', { code: 'packing_addon_off' });
}
}
/** The packing items of the trip this user may see, in the order Packing lists them. */
private async visibleItems(tripId: number, userId: number): Promise<VisiblePackingItem[]> {
const items: VisiblePackingItem[] = await this.packing.listItems(tripId, userId);
return items;
}
/** What the tour points at; null while it points at nothing. */
async read(tripId: number | string, placeId: number | string): Promise<TourPackingLinkResponse> {
const { placeId: pid } = await this.tourIn(tripId, placeId);
return { packing_link: (await this.links.findForTour(pid)) ?? null };
}
/**
* Points the tour at the whole packing list or at one of its categories,
* replacing what it pointed at. A category must hold an item this user can
* see, so a link never names one they cannot reach. Answers the tour with
* its new link.
*/
async link(
tripId: string,
placeId: string,
body: TourPackingLinkRequest,
userId: number,
socketId?: string,
): Promise<TourDetailResponse> {
const tour = await this.tourIn(tripId, placeId);
await this.requirePacking();
if (body.category !== null) {
const items = await this.visibleItems(tour.tripId, userId);
if (!items.some((item) => item.category === body.category)) {
throw new DomainError(400, 'Packing category not found', { code: 'packing_category_not_found' });
}
}
await this.links.put(tour.placeId, body.category, userId);
return await this.announce(tripId, tour.placeId, socketId);
}
/** Lets go of what the tour points at. The packing list and its items stay as they are. */
async unlink(tripId: string, placeId: string, socketId?: string): Promise<TourDetailResponse> {
const tour = await this.tourIn(tripId, placeId);
await this.links.remove(tour.placeId);
return await this.announce(tripId, tour.placeId, socketId);
}
/** Tells the trip's other clients and answers the tour with its link as it is now. */
private async announce(tripId: string, placeId: number, socketId?: string): Promise<TourDetailResponse> {
this.tours.broadcastToursChanged(tripId, [placeId], socketId);
const detail = await this.tours.getTour(tripId, String(placeId));
return { ...detail, packing_link: (await this.links.findForTour(placeId)) ?? null };
}
/**
* The link as an assistant reads it: the items the user may see of what it
* points at, and the tour's facts as context. Reading changes nothing.
*/
async view(tripId: number, placeId: number, userId: number): Promise<TourPackingView> {
const { tour } = await this.tourIn(tripId, placeId);
await this.requirePacking();
const link = (await this.links.findForTour(placeId)) ?? null;
const context = {
name: tour.name,
tour_type: tour.tour_type,
distance_km: tour.distance,
elevation_gain_m: tour.elevation_gain,
elevation_loss_m: tour.elevation_loss,
walking_minutes: tour.duration,
planned_duration_minutes: tour.planned_duration_minutes,
difficulty: tour.difficulty,
max_hiking_difficulty: tour.max_hiking_difficulty,
};
if (!link) return { packing_link: null, state: null, items: [], packed: 0, total: 0, tour: context };
const linked = linkedPackingItems(link, await this.visibleItems(tripId, userId));
return {
packing_link: link,
state: linked.state,
items: linked.items.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
quantity: item.quantity,
packed: Boolean(item.checked),
})),
packed: linked.packed,
total: linked.items.length,
tour: context,
};
}
}
+104
View File
@@ -0,0 +1,104 @@
import { ADDON_IDS } from '../../addons';
import { canRead, canReadTrips, canWrite } from '../../mcp/scopes';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
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 { TourPackingLinkService } from './tour-packing-link.service';
import { idSchema, tourPackingLinkRequestSchema, type TourPackingLinkRequest } from '@trek/shared';
const toursOn = addonGate(ADDON_IDS.TOURS);
const packingOn = addonGate(ADDON_IDS.PACKING);
/** A tour's packing link reaches into Packing, so both addons have to be on. */
async function tourPackingOn(ctx: McpContext, self: HasAddons): Promise<boolean> {
return (await toursOn(ctx, self)) && (await packingOn(ctx, self));
}
const tourIds = {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
};
/**
* P2D-XD-01: the packing list a tour points at, over the same service and the
* same checks as REST: reading needs the trip, pointing the tour somewhere and
* letting go need 'place_edit'. Packing keeps its items; nothing here adds,
* ticks off or removes one.
*/
@McpController()
export class TourPackingMcp {
constructor(
private readonly packingLinks: TourPackingLinkService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
/** The trip open to the user and, for a write, 'place_edit' on it; the refusal to answer, or null. */
private async refusal(tripId: number, userId: number, write: boolean) {
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
if (write && !(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
return null;
}
@Tool({
name: 'get_tour_packing',
description:
'Read the packing list a saved tour points at: packing_link names it (category null for the whole packing list of the trip, otherwise one category of it; null while the tour points at nothing), state says how it reads (ready, empty while it holds no item yet, or missing when its category has no item the user can see any more, renamed, cleared or kept to other travellers), and items lists what the user may see of it with quantity and whether each is packed. tour carries the tour’s own facts (type, distance in km, ascent and descent in metres, walking and planned minutes, difficulty, the SAC limit of a hike), unknown ones null, as context for suggesting what to pack. Suggestions stay suggestions: add an item only through the packing tools and only when the user asks for it.',
inputSchema: tourIds,
annotations: TOOL_ANNOTATIONS_READONLY,
when: tourPackingOn,
access: (ctx) => canReadTrips(ctx.scopes) && canRead(ctx.scopes, 'packing'),
})
async getTourPacking({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId, false);
if (refused) return refused;
return ok(await this.packingLinks.view(tripId, placeId, ctx.userId));
}
@Tool({
name: 'link_tour_packing',
description:
'Point a saved tour at the packing list of its trip: category null for the whole list, or the name of one of its categories exactly as its items carry it (one the user can see an item of). It replaces what the tour pointed at before. Nothing is copied or changed in the packing list. Needs place_edit on the trip and the Packing addon.',
inputSchema: { ...tourIds, category: tourPackingLinkRequestSchema.shape.category },
annotations: TOOL_ANNOTATIONS_WRITE,
when: tourPackingOn,
access: (ctx) => canWrite(ctx.scopes, 'trips') && canRead(ctx.scopes, 'packing'),
})
async linkTourPacking(
{ tripId, placeId, category }: { tripId: number; placeId: number } & TourPackingLinkRequest,
ctx: McpContext,
) {
const refused = await this.refusal(tripId, ctx.userId, true);
if (refused) return refused;
const detail = await this.packingLinks.link(String(tripId), String(placeId), { category }, ctx.userId);
return ok({ packing_link: detail.packing_link ?? null });
}
@Tool({
name: 'unlink_tour_packing',
description:
'Let a saved tour stop pointing at its packing list. The packing list, its categories and its items stay exactly as they are. Needs place_edit on the trip.',
inputSchema: tourIds,
annotations: TOOL_ANNOTATIONS_WRITE,
when: tourPackingOn,
access: { group: 'trips', mode: 'write' },
})
async unlinkTourPacking({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId, true);
if (refused) return refused;
const detail = await this.packingLinks.unlink(String(tripId), String(placeId));
return ok({ packing_link: detail.packing_link ?? null });
}
}
+14 -1
View File
@@ -4,6 +4,7 @@ import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity';
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity';
import { TourSources } from '../../db/entities/TourSources.entity';
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
import { TourTypes } from '../../db/entities/TourTypes.entity';
@@ -15,6 +16,7 @@ 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';
import { PackingModule } from '../packing/packing.module';
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
@@ -46,6 +48,9 @@ import { TourLibraryController, TourTakeOverController } from './tour-library.co
import { TourLibraryMcp } from './tour-library.mcp';
import { TourLibraryService } from './tour-library.service';
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
import { TourPackingLinkController } from './tour-packing-link.controller';
import { TourPackingLinkService } from './tour-packing-link.service';
import { TourPackingMcp } from './tour-packing.mcp';
import { PaddleCheckCache } from './tour-paddle-check.cache';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
@@ -90,7 +95,9 @@ import { Module } from '@nestjs/common';
* TourActivityStatsService, over the trips TripMembershipModule says they can
* 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.
* its live notice to its members come from CollectionsService. A tour points at
* the packing list of its trip (P2D-XD-01) through a row of its own; the list
* and who sees which of its items stay PackingService's.
*/
@Module({
imports: [
@@ -116,6 +123,8 @@ import { Module } from '@nestjs/common';
AssignmentsDomainModule,
// A collection keeps whole tours (LIB-03): its roles decide who sees, keeps and removes one.
CollectionsModule,
// A tour's packing link is read against the items Packing lets the user see (P2D-XD-01).
PackingModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
@@ -123,6 +132,7 @@ import { Module } from '@nestjs/common';
TourWaypoints,
TourSources,
TourObstacleReviews,
TourPackingLinks,
TourStationVisits,
TourChangeSteps,
TourActivities,
@@ -147,6 +157,7 @@ import { Module } from '@nestjs/common';
ToursWandererController,
TourLibraryController,
CollectionToursController,
TourPackingLinkController,
],
providers: [
ToursService,
@@ -171,6 +182,7 @@ import { Module } from '@nestjs/common';
TourActivitiesService,
TourActivityStatsService,
CollectionToursService,
TourPackingLinkService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -180,6 +192,7 @@ import { Module } from '@nestjs/common';
TourActivitiesMcp,
TourActivityStatsMcp,
CollectionToursMcp,
TourPackingMcp,
],
})
export class ToursModule {}
+4 -4
View File
@@ -733,8 +733,9 @@ export class TripsService {
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
}
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions and the waypoints its
// route was planned through. The facet row goes first, since the others reference it; without them it is no Tour.
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions, its packing link and
// the waypoints its route was planned through. The facet row goes first, since the others reference it;
// without them it is no Tour.
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
const copiedTours = new Map<number, number>();
for (const { place_id, ...tour } of oldTours) {
@@ -743,8 +744,7 @@ export class TripsService {
await this.toursRepo.insertCopy(newPlaceId, tour, placeMap);
copiedTours.set(place_id, newPlaceId);
}
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copySource(from, to)));
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyObstacleReviews(from, to)));
for (const [from, to] of copiedTours) await this.toursRepo.copyTourRelations(from, to);
const waypointsByPlace = new Map<number, TourWaypointRow[]>();
for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) {
@@ -0,0 +1,230 @@
/**
* A tour's packing link (P2D-XD-01), 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 trip 5 and its packing list, user 2 is a member of
* trip 5 whom the spy denies 'place_edit' and who keeps one item to
* themselves, user 3 has no access to the trip.
*/
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
import { PackingItems } from '../../src/db/entities/PackingItems.entity';
import { Places } from '../../src/db/entities/Places.entity';
import { TourPackingLinks } from '../../src/db/entities/TourPackingLinks.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 { makePackingItem } from '../helpers/factories/packing';
import { makePlace } from '../helpers/factories/places';
import { countRows, deleteRows, findRows, 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;
const OWNER = 1;
const MEMBER = 2;
const OUTSIDER = 3;
const tourBody = {
name: 'Ridge walk',
route_geometry: [
[47, 11, 600],
[47.01, 11.01, 700],
],
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 3,
duration_seconds: 3600,
};
describe('Tour packing link 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 path = (placeId: number) => `/api/trips/5/tours/${placeId}/packing-link`;
const createTour = async () => {
const res = await request(server).post('/api/trips/5/tours').set(as(OWNER)).send(tourBody);
expect(res.status).toBe(201);
return res.body.tour.place_id as number;
};
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' }]);
await addTripMember(orm, 5, 2);
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, 'packing', true);
realtime.broadcastMock.mockClear();
await deleteRows(orm, TourPackingLinks);
await deleteRows(orm, PackingItems);
await deleteRows(orm, DayAssignments);
await deleteRows(orm, Places);
await makePackingItem(orm, 5, { name: 'Boots', category: 'Hiking gear', sort_order: 1 });
await makePackingItem(orm, 5, { name: 'Diary', category: 'Secret', is_private: 1, owner: MEMBER, sort_order: 2 });
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('links a category, reads it back, moves it to the whole list and lets go, leaving every item as it was', async () => {
const tour = await createTour();
const before = await findRows(orm, PackingItems, {}, { id: 'asc' });
const empty = await request(server).get(path(tour)).set(as(MEMBER));
expect(empty.status).toBe(200);
expect(empty.body).toEqual({ packing_link: null });
const linked = await request(server)
.put(path(tour))
.set(as(OWNER))
.set('X-Socket-Id', 'sock-1')
.send({ category: 'Hiking gear' });
expect(linked.status).toBe(200);
expect(linked.body.tour).toMatchObject({ place_id: tour, name: 'Ridge walk' });
expect(linked.body.packing_link).toEqual({ category: 'Hiking gear', linked_at: expect.any(String) });
expect(realtime.broadcastMock).toHaveBeenCalledWith('5', 'tours:changed', { placeIds: [tour] }, 'sock-1');
expect((await request(server).get(path(tour)).set(as(MEMBER))).body).toEqual({
packing_link: linked.body.packing_link,
});
const whole = await request(server).put(path(tour)).set(as(OWNER)).send({ category: null });
expect(whole.status).toBe(200);
expect(whole.body.packing_link.category).toBeNull();
expect(await countRows(orm, TourPackingLinks)).toBe(1);
const removed = await request(server).delete(path(tour)).set(as(OWNER));
expect(removed.status).toBe(200);
expect(removed.body.packing_link).toBeNull();
expect(await countRows(orm, TourPackingLinks)).toBe(0);
expect(await findRows(orm, PackingItems, {}, { id: 'asc' })).toEqual(before);
});
it('refuses a member without place_edit, a stranger, a body it cannot read and a category the user cannot see', async () => {
const tour = await createTour();
expect((await request(server).put(path(tour)).set(as(MEMBER)).send({ category: null })).status).toBe(403);
expect((await request(server).delete(path(tour)).set(as(MEMBER))).status).toBe(403);
expect((await request(server).get(path(tour)).set(as(OUTSIDER))).status).toBe(404);
expect((await request(server).put(path(tour)).set(as(OUTSIDER)).send({ category: null })).status).toBe(404);
expect((await request(server).put(path(tour)).set(as(OWNER)).send({})).status).toBe(400);
expect((await request(server).put(path(tour)).set(as(OWNER)).send({ category: '' })).status).toBe(400);
// The member's private item is not the owner's to point at.
const hidden = await request(server).put(path(tour)).set(as(OWNER)).send({ category: 'Secret' });
expect(hidden.status).toBe(400);
expect(hidden.body).toEqual({ error: 'Packing category not found' });
const unknown = await request(server).put(path(tour)).set(as(OWNER)).send({ category: 'Climbing' });
expect(unknown.status).toBe(400);
expect(await countRows(orm, TourPackingLinks)).toBe(0);
});
it('answers a place that is no tour, a malformed id and switched-off addons as not found', async () => {
const tour = await createTour();
const cafe = await makePlace(orm, 5, { name: 'Cafe' });
const notTour = await request(server).get(path(cafe.id)).set(as(OWNER));
expect(notTour.status).toBe(404);
expect(notTour.body).toEqual({ error: 'Tour not found' });
expect((await request(server).get('/api/trips/5/tours/x/packing-link').set(as(OWNER))).status).toBe(404);
expect((await request(server).get(path(tour))).status).toBe(401);
await request(server).put(path(tour)).set(as(OWNER)).send({ category: null });
await setAddonEnabled(orm, 'packing', false);
const noPacking = await request(server).put(path(tour)).set(as(OWNER)).send({ category: 'Hiking gear' });
expect(noPacking.status).toBe(404);
expect(noPacking.body).toEqual({ error: 'Packing addon is not enabled' });
// The link stays readable and can be let go while Packing is off.
expect((await request(server).get(path(tour)).set(as(OWNER))).body.packing_link.category).toBeNull();
expect((await request(server).delete(path(tour)).set(as(OWNER))).status).toBe(200);
await setAddonEnabled(orm, 'tours', false);
const noTours = await request(server).get(path(tour)).set(as(OWNER));
expect(noTours.status).toBe(404);
expect(noTours.body).toEqual({ error: 'Tours addon is not enabled' });
});
});
+11
View File
@@ -1,4 +1,5 @@
import { TourActivities } from '../../../src/db/entities/TourActivities.entity';
import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.entity';
import { TourStationVisits, type TTourStationVisitsStatus } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints, type TTourWaypointsImportance } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
@@ -86,3 +87,13 @@ export function makeTourActivity(
): Promise<TourActivityRow> {
return createRow(orm, TourActivities, { place: placeId, trackPlace: trackPlaceId }, overrides);
}
/** P2D-XD-01: points the tour at its trip's packing list (category null) or at one category of it. */
export async function linkTourPacking(
orm: FactoryOrm,
placeId: number,
category: string | null,
linkedBy: number | null = null,
): Promise<void> {
await insertRow(orm, TourPackingLinks, { place: placeId, category, linkedByRef: linkedBy });
}
+4 -3
View File
@@ -44,14 +44,15 @@ describe('ALL_ENTITIES', () => {
* + 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
* + collection_tours, the whole tours a collection keeps)
* + collection_tours, the whole tours a collection keeps
* + tour_packing_links, the packing list or category a tour points at)
* 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 147 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(147);
it('ENT-003: exactly 148 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(148);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -77,6 +77,7 @@ const UNNUMBERED = [
'Migration20200101046000_a_tour_waypoint_can_end_a_stage',
'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour',
'Migration20200101046100_a_collection_keeps_whole_tours',
'Migration20200101046110_a_tour_points_at_its_packing_list',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,105 @@
/**
* `TourPackingLinksRepository` (P2D-XD-01): the packing list, or the category
* of it, a tour points at. Seeded through the ORM factories over the migrated
* snapshot schema.
*/
import { PackingItems } from '../../../../src/db/entities/PackingItems.entity';
import { Places } from '../../../../src/db/entities/Places.entity';
import { TourPackingLinks } from '../../../../src/db/entities/TourPackingLinks.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { TourPackingLinksRepository } from '../../../../src/db/repositories/TourPackingLinks.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makePackingItem } from '../../../helpers/factories/packing';
import { makePlace } from '../../../helpers/factories/places';
import { countRows, deleteRows, findRow, updateRows } from '../../../helpers/factories/rows';
import { linkTourPacking, makeTour } from '../../../helpers/factories/tours';
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: TourPackingLinksRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourPackingLinks);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** A trip with a hike and a packing item, the user who links and a second tour that points at nothing. */
async function fixture() {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const hike = await makePlace(t, trip.id, { name: 'Ridge' });
await makeTour(t, hike.id);
const walk = await makePlace(t, trip.id, { name: 'Old town' });
await makeTour(t, walk.id, { tourTypeRef: 'walk' });
const item = await makePackingItem(t, trip.id, { name: 'Boots', category: 'Hiking gear' });
return { user, trip, hike, walk, item };
}
describe('TourPackingLinksRepository', () => {
it('TPLREPO-001: a tour without a link reads as undefined', async () => {
const { hike } = await fixture();
expect(await repo.findForTour(hike.id)).toBeUndefined();
});
it('TPLREPO-002: put points the tour at a category, or at the whole list with null', async () => {
const { user, hike, walk } = await fixture();
await repo.put(hike.id, 'Hiking gear', user.id);
await repo.put(walk.id, null, user.id);
expect(await repo.findForTour(hike.id)).toEqual({ category: 'Hiking gear', linked_at: expect.any(String) });
expect(await repo.findForTour(walk.id)).toEqual({ category: null, linked_at: expect.any(String) });
expect((await findRow(t, TourPackingLinks, { place: hike.id }))?.linked_by).toBe(user.id);
});
it('TPLREPO-003: a second put replaces what the tour pointed at, on the same row, with a new time', async () => {
const { user, hike } = await fixture();
const { user: other } = await makeUser(t);
await repo.put(hike.id, 'Hiking gear', user.id);
await updateRows(t, TourPackingLinks, { place: hike.id }, { linked_at: '2020-01-01 00:00:00' });
await repo.put(hike.id, null, other.id);
const row = await findRow(t, TourPackingLinks, { place: hike.id });
expect(row).toMatchObject({ category: null, linked_by: other.id });
expect(row?.linked_at).not.toBe('2020-01-01 00:00:00');
expect(await countRows(t, TourPackingLinks)).toBe(1);
});
it('TPLREPO-004: remove lets go of the link and says whether there was one, and leaves the packing items alone', async () => {
const { user, hike, walk } = await fixture();
await repo.put(hike.id, 'Hiking gear', user.id);
expect(await repo.remove(hike.id)).toBe(true);
expect(await repo.remove(hike.id)).toBe(false);
expect(await repo.remove(walk.id)).toBe(false);
expect(await repo.findForTour(hike.id)).toBeUndefined();
expect(await countRows(t, PackingItems)).toBe(1);
});
it('TPLREPO-005: the link goes with its tour, and turns its author unknown when that account goes', async () => {
const { user, hike, walk } = await fixture();
const { user: linker } = await makeUser(t);
await linkTourPacking(t, hike.id, 'Hiking gear', user.id);
await linkTourPacking(t, walk.id, null, linker.id);
await deleteRows(t, Users, { id: linker.id });
const kept = await findRow(t, TourPackingLinks, { place: walk.id });
expect(kept?.category).toBeNull();
expect(kept?.linked_by ?? null).toBeNull();
await deleteRows(t, Places, { id: hike.id });
expect(await repo.findForTour(hike.id)).toBeUndefined();
expect(await repo.findForTour(walk.id)).toEqual({ category: null, linked_at: expect.any(String) });
});
});
@@ -6,6 +6,7 @@
*/
import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entity';
import { Places } from '../../../../src/db/entities/Places.entity';
import { TourPackingLinks } from '../../../../src/db/entities/TourPackingLinks.entity';
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../../src/db/entities/Tours.entity';
@@ -14,7 +15,8 @@ import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { makePlace } from '../../../helpers/factories/places';
import { findRows, insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
import { countRows, findRow, findRows, insertRow, insertRows, updateRows } from '../../../helpers/factories/rows';
import { linkTourPacking } from '../../../helpers/factories/tours';
import { makeDay } from '../../../helpers/factories/trips';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
@@ -667,4 +669,27 @@ describe('ToursRepository writes', () => {
});
expect((await repo.findProvenance(other.id, copiedPlanned.id))?.source_instance).toBeNull();
});
it('TOURREPO-012: a trip copy takes a tour’s packing link onto its copy, and nothing for a tour that had none', async () => {
const { trip, other } = fixture();
const linked = createPlace(testDb, trip.id);
await repo.insertTour({ ...METRICS, place_id: linked.id });
await linkTourPacking(t, linked.id, 'Hiking gear');
const plain = createPlace(testDb, trip.id);
await repo.insertTour({ ...METRICS, place_id: plain.id });
const linkedCopy = createPlace(testDb, other.id);
await repo.insertTour({ ...METRICS, place_id: linkedCopy.id });
const plainCopy = createPlace(testDb, other.id);
await repo.insertTour({ ...METRICS, place_id: plainCopy.id });
await repo.copyTourRelations(linked.id, linkedCopy.id);
await repo.copyTourRelations(plain.id, plainCopy.id);
const source = await findRow(t, TourPackingLinks, { place: linked.id });
expect(await findRow(t, TourPackingLinks, { place: linkedCopy.id })).toMatchObject({
category: 'Hiking gear',
linked_at: source?.linked_at,
});
expect(await countRows(t, TourPackingLinks, { place: plainCopy.id })).toBe(0);
});
});
@@ -0,0 +1,68 @@
/**
* TourPackingLinkController (P2D-XD-01): the route metadata of a tour's packing
* link and its delegation to TourPackingLinkService. Parity with the MCP tools
* lives here too: reading needs the trip only, both writes 'place_edit', the
* same action the tools check.
*/
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 { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import { TourPackingLinkController } from '../../../src/nest/tours/tour-packing-link.controller';
import type { TourPackingLinkService } from '../../../src/nest/tours/tour-packing-link.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 link = { category: 'Hiking gear', linked_at: '2026-10-20 09:00:00' };
const detail = { tour: { place_id: 3 }, waypoints: [], packing_link: link };
function controllerWith() {
const service = {
read: vi.fn(async () => ({ packing_link: link })),
link: vi.fn(async () => detail),
unlink: vi.fn(async () => ({ ...detail, packing_link: null })),
};
return { service, controller: new TourPackingLinkController(service as unknown as TourPackingLinkService) };
}
describe('TourPackingLinkController', () => {
it('TPL-CTL-001: sits behind the addon guard before auth and trip access; only the writes need place_edit', () => {
expect(Reflect.getMetadata(PATH_METADATA, TourPackingLinkController)).toBe('api/trips/:tripId/tours');
expect(Reflect.getMetadata(GUARDS_METADATA, TourPackingLinkController)).toEqual([
AddonGuard,
JwtAuthGuard,
TripAccessGuard,
]);
expect(Reflect.getMetadata(REQUIRE_ADDON, TourPackingLinkController)).toEqual({ addonId: 'tours', label: 'Tours' });
const routes = [
['read', RequestMethod.GET, undefined],
['link', RequestMethod.PUT, 'place_edit'],
['unlink', RequestMethod.DELETE, 'place_edit'],
] as const;
for (const [handler, method, permission] of routes) {
const fn = TourPackingLinkController.prototype[handler];
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(':placeId/packing-link');
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
}
});
it('TPL-CTL-002: hands the trip, tour, body, user and socket id to the service and answers what it answers', async () => {
const { service, controller } = controllerWith();
const user = { id: 4 } as User;
expect(await controller.read('7', '3')).toEqual({ packing_link: link });
expect(service.read).toHaveBeenCalledExactlyOnceWith('7', '3');
expect(await controller.link(user, '7', '3', { category: 'Hiking gear' }, 'sock-1')).toBe(detail);
expect(service.link).toHaveBeenCalledExactlyOnceWith('7', '3', { category: 'Hiking gear' }, 4, 'sock-1');
expect((await controller.unlink('7', '3', 'sock-2')).packing_link).toBeNull();
expect(service.unlink).toHaveBeenCalledExactlyOnceWith('7', '3', 'sock-2');
});
});
@@ -0,0 +1,231 @@
/**
* TourPackingLinkService (P2D-XD-01): a tour points at the packing list of its
* trip or at one of its categories, read against the items Packing lets the
* user see. The service comes out of ToursModule over a snapshot test db; rows
* are seeded through the ORM factories and broadcasts go to a FakeRealtimeService.
*/
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.entity';
import { DomainError } from '../../../src/nest/common/domain-error';
import { TourPackingLinkService } from '../../../src/nest/tours/tour-packing-link.service';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePackingItem } from '../../helpers/factories/packing';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRows } from '../../helpers/factories/rows';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { linkTourPacking, 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 { MikroORM } from '@mikro-orm/core';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let links: TourPackingLinkService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
links = t.get(TourPackingLinkService);
});
beforeEach(async () => {
resetTestDb(db);
t.realtime.broadcastMock.mockClear();
await setAddonEnabled(orm(), 'packing', true);
});
afterAll(async () => {
await t.close();
db.close();
});
/**
* A trip of the owner with a member: a hike, a plain place, and a packing list
* whose "Hiking gear" holds two items (one packed), "Snacks" only the
* placeholder of a new category, and "Secret" an item the member keeps to
* themselves. Another trip has a tour of its own.
*/
async function fixture() {
const { user: owner } = await makeUser(orm());
const { user: member } = await makeUser(orm());
const trip = await makeTrip(orm(), owner.id, { title: 'Alps' });
await addTripMember(orm(), trip.id, member.id);
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
await makeTour(orm(), hike.id, {
distance: 12.5,
elevation_gain: 800,
duration: 240,
difficulty: 'T3',
max_hiking_difficulty: 3,
});
const cafe = await makePlace(orm(), trip.id, { name: 'Cafe' });
const boots = await makePackingItem(orm(), trip.id, {
name: 'Boots',
category: 'Hiking gear',
checked: 1,
sort_order: 1,
});
const jacket = await makePackingItem(orm(), trip.id, {
name: 'Rain jacket',
category: 'Hiking gear',
quantity: 2,
sort_order: 2,
});
await makePackingItem(orm(), trip.id, { name: '...', category: 'Snacks', sort_order: 3 });
await makePackingItem(orm(), trip.id, {
name: 'Diary',
category: 'Secret',
is_private: 1,
owner: member.id,
sort_order: 4,
});
const other = await makeTrip(orm(), owner.id, { title: 'Coast' });
const otherTour = await makePlace(orm(), other.id, { name: 'Cliffs' });
await makeTour(orm(), otherTour.id);
return { owner, member, trip, hike, cafe, boots, jacket, other, otherTour };
}
describe('TourPackingLinkService', () => {
it('TPL-SVC-001: a tour points at nothing until it is linked, then at the whole list, and says so to the trip', async () => {
const f = await fixture();
expect(await links.read(f.trip.id, f.hike.id)).toEqual({ packing_link: null });
const answer = await links.link(String(f.trip.id), String(f.hike.id), { category: null }, f.owner.id, 'sock-1');
expect(answer.tour.place_id).toBe(f.hike.id);
expect(answer.packing_link).toEqual({ category: null, linked_at: expect.any(String) });
expect(t.realtime.broadcastMock).toHaveBeenCalledWith(
String(f.trip.id),
'tours:changed',
{ placeIds: [f.hike.id] },
'sock-1',
);
expect(await links.read(String(f.trip.id), String(f.hike.id))).toEqual({ packing_link: answer.packing_link });
});
it('TPL-SVC-002: a category is linked by its exact name, and a new link replaces the old one', async () => {
const f = await fixture();
await links.link(String(f.trip.id), String(f.hike.id), { category: null }, f.owner.id);
const answer = await links.link(String(f.trip.id), String(f.hike.id), { category: 'Hiking gear' }, f.owner.id);
expect(answer.packing_link?.category).toBe('Hiking gear');
// A category that only holds the placeholder of a new one is there too.
await links.link(String(f.trip.id), String(f.hike.id), { category: 'Snacks' }, f.owner.id);
expect(await countRows(orm(), TourPackingLinks)).toBe(1);
});
it('TPL-SVC-003: a category the user cannot see an item of is refused, a private one of another member included', async () => {
const f = await fixture();
for (const category of ['Climbing', 'hiking gear', 'Secret']) {
await expect(
links.link(String(f.trip.id), String(f.hike.id), { category }, f.owner.id),
).rejects.toMatchObject({ status: 400, publicMessage: 'Packing category not found' });
}
// The member who keeps it may link it.
const own = await links.link(String(f.trip.id), String(f.hike.id), { category: 'Secret' }, f.member.id);
expect(own.packing_link?.category).toBe('Secret');
});
it('TPL-SVC-004: with Packing off nothing is linked, and an existing link can still be let go', async () => {
const f = await fixture();
await linkTourPacking(orm(), f.hike.id, 'Hiking gear', f.owner.id);
await setAddonEnabled(orm(), 'packing', false);
const refused = await links
.link(String(f.trip.id), String(f.hike.id), { category: null }, f.owner.id)
.catch((err: unknown) => err);
expect(refused).toBeInstanceOf(DomainError);
expect(refused).toMatchObject({ status: 404, publicMessage: 'Packing addon is not enabled' });
await expect(links.view(f.trip.id, f.hike.id, f.owner.id)).rejects.toMatchObject({ status: 404 });
const answer = await links.unlink(String(f.trip.id), String(f.hike.id));
expect(answer.packing_link).toBeNull();
});
it('TPL-SVC-005: removing the link leaves the packing list and every item as it was', async () => {
const f = await fixture();
await linkTourPacking(orm(), f.hike.id, 'Hiking gear', f.owner.id);
const before = await findRows(orm(), PackingItems, {}, { id: 'asc' });
const answer = await links.unlink(String(f.trip.id), String(f.hike.id), 'sock-2');
expect(answer.packing_link).toBeNull();
expect(await links.read(f.trip.id, f.hike.id)).toEqual({ packing_link: null });
expect(await findRows(orm(), PackingItems, {}, { id: 'asc' })).toEqual(before);
expect(t.realtime.broadcastMock).toHaveBeenCalledWith(
String(f.trip.id),
'tours:changed',
{ placeIds: [f.hike.id] },
'sock-2',
);
// Letting go of nothing is no error.
expect((await links.unlink(String(f.trip.id), String(f.hike.id))).packing_link).toBeNull();
});
it('TPL-SVC-006: a tour of another trip, a plain place or a malformed id is not found', async () => {
const f = await fixture();
const attempts = [
() => links.read(f.trip.id, f.otherTour.id),
() => links.read(f.trip.id, f.cafe.id),
() => links.read('abc', f.hike.id),
() => links.read(f.trip.id, '1.5'),
() => links.link(String(f.trip.id), String(f.otherTour.id), { category: null }, f.owner.id),
() => links.unlink(String(f.trip.id), String(f.cafe.id)),
() => links.view(f.other.id, f.hike.id, f.owner.id),
];
for (const attempt of attempts) {
await expect(attempt()).rejects.toMatchObject({ status: 404, publicMessage: 'Tour not found' });
}
expect(await countRows(orm(), TourPackingLinks)).toBe(0);
});
it('TPL-SVC-007: an assistant reads the linked items it may see with the packed count and the tour as context', async () => {
const f = await fixture();
const unlinked = await links.view(f.trip.id, f.hike.id, f.owner.id);
expect(unlinked).toEqual({
packing_link: null,
state: null,
items: [],
packed: 0,
total: 0,
tour: {
name: 'Ridge',
tour_type: 'hike',
distance_km: 12.5,
elevation_gain_m: 800,
elevation_loss_m: null,
walking_minutes: 240,
planned_duration_minutes: null,
difficulty: 'T3',
max_hiking_difficulty: 3,
},
});
await linkTourPacking(orm(), f.hike.id, 'Hiking gear', f.owner.id);
const gear = await links.view(f.trip.id, f.hike.id, f.owner.id);
expect(gear).toMatchObject({
packing_link: { category: 'Hiking gear' },
state: 'ready',
items: [
{ id: f.boots.id, name: 'Boots', category: 'Hiking gear', quantity: 1, packed: true },
{ id: f.jacket.id, name: 'Rain jacket', category: 'Hiking gear', quantity: 2, packed: false },
],
packed: 1,
total: 2,
});
});
it('TPL-SVC-008: what the link reads as follows the viewer: empty, missing, or the whole list without others’ private items', async () => {
const f = await fixture();
await linkTourPacking(orm(), f.hike.id, 'Snacks', f.owner.id);
expect((await links.view(f.trip.id, f.hike.id, f.owner.id)).state).toBe('empty');
await links.link(String(f.trip.id), String(f.hike.id), { category: 'Secret' }, f.member.id);
expect(await links.view(f.trip.id, f.hike.id, f.owner.id)).toMatchObject({ state: 'missing', items: [], total: 0 });
expect(await links.view(f.trip.id, f.hike.id, f.member.id)).toMatchObject({ state: 'ready', total: 1 });
await links.link(String(f.trip.id), String(f.hike.id), { category: null }, f.owner.id);
const whole = await links.view(f.trip.id, f.hike.id, f.owner.id);
expect(whole.items.map((item) => item.name)).toEqual(['Boots', 'Rain jacket']);
expect(whole).toMatchObject({ state: 'ready', packed: 1, total: 2 });
});
});
@@ -0,0 +1,151 @@
/**
* TourPackingMcp (P2D-XD-01): the packing list a tour points at, 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 { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.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 { TourPackingMcp } from '../../../src/nest/tours/tour-packing.mcp';
import { ToursModule } from '../../../src/nest/tours/tours.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePackingItem } from '../../helpers/factories/packing';
import { makePlace } from '../../helpers/factories/places';
import { countRows } from '../../helpers/factories/rows';
import { setAddonEnabled } from '../../helpers/factories/settings';
import { 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 { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let orm: TestOrm;
let mcp: TourPackingMcp;
beforeAll(async () => {
t = await createTestModule({ db, imports: [ToursModule] });
mcp = t.get(TourPackingMcp);
orm = await createTestOrm(db);
});
beforeEach(async () => {
resetTestDb(db);
orm.clear();
vi.restoreAllMocks();
await setAddonEnabled(orm, 'packing', 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);
}
/** A trip with a hike and two items of "Hiking gear", one packed; a user who cannot open the trip. */
async function fixture() {
const { user } = await makeUser(orm);
const { user: outsider } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Alps' });
const hike = await makePlace(orm, trip.id, { name: 'Ridge' });
await makeTour(orm, hike.id, { distance: 9 });
await makePackingItem(orm, trip.id, { name: 'Boots', category: 'Hiking gear', checked: 1, sort_order: 1 });
await makePackingItem(orm, trip.id, { name: 'Map', category: 'Hiking gear', sort_order: 2 });
return { user, outsider, trip, hike };
}
describe('TourPackingMcp', () => {
it('TPL-MCP-001: links a category, reads its items with the tour as context, then lets go of it', async () => {
const f = await fixture();
const ids = { tripId: f.trip.id, placeId: f.hike.id };
expect(read(await mcp.getTourPacking(ids, ctx(f.user.id)))).toMatchObject({
packing_link: null,
state: null,
tour: { name: 'Ridge', tour_type: 'hike', distance_km: 9 },
});
const linked = read(await mcp.linkTourPacking({ ...ids, category: 'Hiking gear' }, ctx(f.user.id)));
expect(linked).toEqual({ packing_link: { category: 'Hiking gear', linked_at: expect.any(String) } });
expect(read(await mcp.getTourPacking(ids, ctx(f.user.id)))).toMatchObject({
state: 'ready',
items: [
{ name: 'Boots', packed: true },
{ name: 'Map', packed: false },
],
packed: 1,
total: 2,
});
expect(read(await mcp.unlinkTourPacking(ids, ctx(f.user.id)))).toEqual({ packing_link: null });
expect(await countRows(orm, TourPackingLinks)).toBe(0);
expect(await countRows(orm, PackingItems)).toBe(2);
});
it('TPL-MCP-002: refuses a trip the user cannot open, and a write without place_edit, as REST does', async () => {
const f = await fixture();
const ids = { tripId: f.trip.id, placeId: f.hike.id };
const noTrip = { error: 'Trip not found or access denied.' };
expect(read(await mcp.getTourPacking(ids, ctx(f.outsider.id)))).toEqual(noTrip);
expect(read(await mcp.linkTourPacking({ ...ids, category: null }, ctx(f.outsider.id)))).toEqual(noTrip);
expect(read(await mcp.unlinkTourPacking(ids, ctx(f.outsider.id)))).toEqual(noTrip);
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
expect((await mcp.linkTourPacking({ ...ids, category: null }, ctx(f.user.id))).isError).toBe(true);
expect((await mcp.unlinkTourPacking(ids, ctx(f.user.id))).isError).toBe(true);
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
// Reading asks for no permission beyond the trip.
expect((await mcp.getTourPacking(ids, ctx(f.user.id))).isError).toBeUndefined();
expect(await countRows(orm, TourPackingLinks)).toBe(0);
});
it('TPL-MCP-003: a category the user cannot see is refused through the same service as REST', async () => {
const f = await fixture();
await expect(
mcp.linkTourPacking({ tripId: f.trip.id, placeId: f.hike.id, category: 'Climbing' }, ctx(f.user.id)),
).rejects.toThrow('Packing category not found');
await expect(
mcp.getTourPacking({ tripId: f.trip.id, placeId: 999_999 }, ctx(f.user.id)),
).rejects.toThrow('Tour not found');
});
it('TPL-MCP-004: the tools are there only while Tours and Packing are on, and only for the scopes they need', async () => {
const options = (name: string) => {
const entry = getEntry(TourPackingMcp 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);
for (const name of ['getTourPacking', 'linkTourPacking', 'unlinkTourPacking']) {
const gate = options(name).when;
enabled.mockResolvedValue(true);
expect(await gate?.(ctx(1), mcp)).toBe(true);
enabled.mockImplementation(async (id: string) => id !== 'packing');
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('getTourPacking', ['trips:read', 'packing:read'])).toBe(true);
expect(allowed('getTourPacking', ['trips:read'])).toBe(false);
expect(allowed('linkTourPacking', ['trips:write', 'packing:read'])).toBe(true);
expect(allowed('linkTourPacking', ['trips:write'])).toBe(false);
expect(allowed('linkTourPacking', ['trips:read', 'packing:write'])).toBe(false);
expect(allowed('unlinkTourPacking', [])).toEqual({ group: 'trips', mode: 'write' });
});
});
@@ -0,0 +1,64 @@
/**
* TripsService.copy and a tour's packing link (P2D-XD-01): the copied tour
* points at the same packing list or category of the copied trip, whose
* packing list is copied too, and the source keeps its own link.
*
* Real in-memory SQLite over the container: the service comes out of
* TripsModule, and the rows are seeded and read through the ORM factories.
*/
import { PackingItems } from '../../../src/db/entities/PackingItems.entity';
import { Places } from '../../../src/db/entities/Places.entity';
import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.entity';
import { TripsModule } from '../../../src/nest/trips/trips.module';
import { TripsService } from '../../../src/nest/trips/trips.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePackingItem } from '../../helpers/factories/packing';
import { makePlace } from '../../helpers/factories/places';
import { countRows, findRow } from '../../helpers/factories/rows';
import { linkTourPacking, makeTour } from '../../helpers/factories/tours';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let svc: TripsService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [TripsModule] });
svc = t.get(TripsService);
});
afterAll(async () => {
await t.close();
db.close();
});
describe('copy: a tour pointing at its packing list (P2D-XD-01)', () => {
it('TRIP-SVC-096: the copied tour points at the same category of the copied list; a tour without a link gets none', async () => {
const { user } = await makeUser(orm());
const trip = await makeTrip(orm(), user.id, { title: 'Alps' });
const hike = await makePlace(orm(), trip.id, { name: 'Ridge' });
await makeTour(orm(), hike.id);
const walk = await makePlace(orm(), trip.id, { name: 'Old town' });
await makeTour(orm(), walk.id, { tourTypeRef: 'walk' });
await makePackingItem(orm(), trip.id, { name: 'Boots', category: 'Hiking gear' });
await linkTourPacking(orm(), hike.id, 'Hiking gear', user.id);
const newTripId = await svc.copy(trip.id, user.id, 'Alps again');
const copiedHike = await findRow(orm(), Places, { trip: newTripId, name: 'Ridge' });
const copiedWalk = await findRow(orm(), Places, { trip: newTripId, name: 'Old town' });
expect(await findRow(orm(), TourPackingLinks, { place: copiedHike?.id })).toMatchObject({
category: 'Hiking gear',
});
expect(await countRows(orm(), TourPackingLinks, { place: copiedWalk?.id })).toBe(0);
expect(await countRows(orm(), PackingItems, { trip: newTripId, category: 'Hiking gear' })).toBe(1);
// The source keeps its own link.
expect(await findRow(orm(), TourPackingLinks, { place: hike.id })).toMatchObject({ category: 'Hiking gear' });
});
});
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+43
View File
@@ -0,0 +1,43 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'قائمة التجهيز',
'tours.packing.none': 'لا توجد قائمة تجهيز مرتبطة',
'tours.packing.link': 'ربط قائمة تجهيز',
'tours.packing.change': 'تغيير',
'tours.packing.whole': 'قائمة التجهيز كاملة',
'tours.packing.packed': 'تم تجهيز {packed} من {count}',
'tours.packing.packed.zero': 'تم تجهيز {packed} من {count}',
'tours.packing.packed.one': 'تم تجهيز {packed} من {count}',
'tours.packing.packed.two': 'تم تجهيز {packed} من {count}',
'tours.packing.packed.few': 'تم تجهيز {packed} من {count}',
'tours.packing.packed.many': 'تم تجهيز {packed} من {count}',
'tours.packing.more': '+{count} أخرى',
'tours.packing.more.zero': '+{count} أخرى',
'tours.packing.more.one': '+{count} أخرى',
'tours.packing.more.two': '+{count} أخرى',
'tours.packing.more.few': '+{count} أخرى',
'tours.packing.more.many': '+{count} أخرى',
'tours.packing.items': '{count} غرض',
'tours.packing.items.zero': '{count} غرض',
'tours.packing.items.one': 'غرض واحد',
'tours.packing.items.two': 'غرضان',
'tours.packing.items.few': '{count} أغراض',
'tours.packing.items.many': '{count} غرضًا',
'tours.packing.allPacked': 'تم تجهيز الكل',
'tours.packing.open': 'فتح قائمة التجهيز',
'tours.packing.missing': 'لم تعد في «{name}» أغراض يمكنك رؤيتها.',
'tours.packing.emptyList': 'قائمة التجهيز ما زالت فارغة.',
'tours.packing.emptyCategory': 'لا توجد أغراض في «{name}» بعد.',
'tours.packing.addonOff': 'قوائم التجهيز متوقفة، لذا لا يمكن عرض القائمة.',
'tours.packing.loadFailed': 'تعذر تحميل ربط قائمة التجهيز.',
'tours.packing.dialogSub': 'مجرد ربط. تبقى القائمة وأغراضها في مكانها.',
'tours.packing.choose': 'الربط بـ',
'tours.packing.confirm': 'ربط',
'tours.packing.unlink': 'إزالة الربط',
'tours.packing.unlinkHint': 'تبقى قائمة التجهيز وأغراضها كما هي',
'tours.packing.linked': 'تم ربط قائمة التجهيز.',
'tours.packing.unlinked': 'تمت إزالة الربط. تبقى قائمة التجهيز كما هي.',
'tours.packing.failed': 'تعذر حفظ الربط.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -73,6 +74,7 @@ const locale = {
...trip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...places,
...inspector,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Baqaj siyahısı',
'tours.packing.none': 'Bağlı baqaj siyahısı yoxdur',
'tours.packing.link': 'Baqaj siyahısı bağla',
'tours.packing.change': 'Dəyiş',
'tours.packing.whole': 'Bütün baqaj siyahısı',
'tours.packing.packed': '{count} əşyadan {packed} yığılıb',
'tours.packing.packed.one': '{count} əşyadan {packed} yığılıb',
'tours.packing.more': 'Daha {count}',
'tours.packing.more.one': 'Daha {count}',
'tours.packing.items': '{count} əşya',
'tours.packing.items.one': '{count} əşya',
'tours.packing.allPacked': 'Hamısı yığılıb',
'tours.packing.open': 'Baqaj siyahısını aç',
'tours.packing.missing': '“{name}” daxilində artıq görə biləcəyiniz əşya yoxdur.',
'tours.packing.emptyList': 'Baqaj siyahısı hələ boşdur.',
'tours.packing.emptyCategory': '“{name}” daxilində hələ əşya yoxdur.',
'tours.packing.addonOff': 'Baqaj siyahıları söndürülüb, ona görə siyahı göstərilə bilmir.',
'tours.packing.loadFailed': 'Baqaj siyahısı bağlantısı yüklənmədi.',
'tours.packing.dialogSub': 'Sadəcə bağlantıdır. Siyahı və əşyaları olduğu yerdə qalır.',
'tours.packing.choose': 'Hara bağlansın',
'tours.packing.confirm': 'Bağla',
'tours.packing.unlink': 'Bağlantını sil',
'tours.packing.unlinkHint': 'Baqaj siyahısı və əşyaları olduğu kimi qalır',
'tours.packing.linked': 'Baqaj siyahısı bağlandı.',
'tours.packing.unlinked': 'Bağlantı silindi. Baqaj siyahısı olduğu kimi qalır.',
'tours.packing.failed': 'Bağlantı saxlanılmadı.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Lista de mala',
'tours.packing.none': 'Nenhuma lista de mala vinculada',
'tours.packing.link': 'Vincular uma lista de mala',
'tours.packing.change': 'Alterar',
'tours.packing.whole': 'Lista de mala inteira',
'tours.packing.packed': '{packed} de {count} na mala',
'tours.packing.packed.one': '{packed} de {count} na mala',
'tours.packing.more': '+{count} a mais',
'tours.packing.more.one': '+{count} a mais',
'tours.packing.items': '{count} objetos',
'tours.packing.items.one': '{count} objeto',
'tours.packing.allPacked': 'Tudo na mala',
'tours.packing.open': 'Abrir a lista de mala',
'tours.packing.missing': '“{name}” não tem mais objetos que você possa ver.',
'tours.packing.emptyList': 'A lista de mala ainda está vazia.',
'tours.packing.emptyCategory': '“{name}” ainda não tem objetos.',
'tours.packing.addonOff': 'As listas de mala estão desativadas, então a lista não pode ser exibida.',
'tours.packing.loadFailed': 'Não foi possível carregar o vínculo com a lista de mala.',
'tours.packing.dialogSub': 'Apenas um vínculo. A lista e seus objetos ficam onde estão.',
'tours.packing.choose': 'Vincular a',
'tours.packing.confirm': 'Vincular',
'tours.packing.unlink': 'Remover vínculo',
'tours.packing.unlinkHint': 'A lista de mala e seus objetos ficam como estão',
'tours.packing.linked': 'Lista de mala vinculada.',
'tours.packing.unlinked': 'Vínculo removido. A lista de mala fica como está.',
'tours.packing.failed': 'Não foi possível salvar o vínculo.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': "Llista d'equipatge",
'tours.packing.none': "Cap llista d'equipatge vinculada",
'tours.packing.link': "Vincula una llista d'equipatge",
'tours.packing.change': 'Canvia',
'tours.packing.whole': "Tota la llista d'equipatge",
'tours.packing.packed': '{packed} de {count} empaquetats',
'tours.packing.packed.one': '{packed} de {count} empaquetat',
'tours.packing.more': '+{count} més',
'tours.packing.more.one': '+{count} més',
'tours.packing.items': '{count} articles',
'tours.packing.items.one': '{count} article',
'tours.packing.allPacked': 'Tot empaquetat',
'tours.packing.open': "Obre la llista d'equipatge",
'tours.packing.missing': '«{name}» ja no té articles que puguis veure.',
'tours.packing.emptyList': "La llista d'equipatge encara és buida.",
'tours.packing.emptyCategory': '«{name}» encara no té articles.',
'tours.packing.addonOff': "Les llistes d'equipatge estan desactivades, així que la llista no es pot mostrar.",
'tours.packing.loadFailed': "No s'ha pogut carregar el vincle amb la llista d'equipatge.",
'tours.packing.dialogSub': 'Només un vincle. La llista i els seus articles es queden on són.',
'tours.packing.choose': 'Vincula amb',
'tours.packing.confirm': 'Vincula',
'tours.packing.unlink': 'Treu el vincle',
'tours.packing.unlinkHint': "La llista d'equipatge i els seus articles queden com són",
'tours.packing.linked': "Llista d'equipatge vinculada.",
'tours.packing.unlinked': "Vincle tret. La llista d'equipatge queda com és.",
'tours.packing.failed': "No s'ha pogut desar el vincle.",
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+34
View File
@@ -0,0 +1,34 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Seznam věcí',
'tours.packing.none': 'Žádný propojený seznam věcí',
'tours.packing.link': 'Propojit seznam věcí',
'tours.packing.change': 'Změnit',
'tours.packing.whole': 'Celý seznam věcí',
'tours.packing.packed': 'Zabaleno {packed} z {count}',
'tours.packing.packed.one': 'Zabaleno {packed} z {count}',
'tours.packing.packed.few': 'Zabaleno {packed} z {count}',
'tours.packing.more': '+{count} dalších',
'tours.packing.more.one': '+{count} další',
'tours.packing.more.few': '+{count} další',
'tours.packing.items': '{count} věcí',
'tours.packing.items.one': '{count} věc',
'tours.packing.items.few': '{count} věci',
'tours.packing.allPacked': 'Vše zabaleno',
'tours.packing.open': 'Otevřít seznam věcí',
'tours.packing.missing': '„{name}“ už nemá žádné věci, které můžete vidět.',
'tours.packing.emptyList': 'Seznam věcí je zatím prázdný.',
'tours.packing.emptyCategory': '„{name}“ zatím nemá žádné věci.',
'tours.packing.addonOff': 'Seznamy věcí jsou vypnuté, proto seznam nelze zobrazit.',
'tours.packing.loadFailed': 'Propojení se seznamem věcí se nepodařilo načíst.',
'tours.packing.dialogSub': 'Jen propojení. Seznam a jeho věci zůstávají, kde jsou.',
'tours.packing.choose': 'Propojit s',
'tours.packing.confirm': 'Propojit',
'tours.packing.unlink': 'Zrušit propojení',
'tours.packing.unlinkHint': 'Seznam věcí a jeho věci zůstanou beze změny',
'tours.packing.linked': 'Seznam věcí propojen.',
'tours.packing.unlinked': 'Propojení zrušeno. Seznam věcí zůstává beze změny.',
'tours.packing.failed': 'Propojení se nepodařilo uložit.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Packliste',
'tours.packing.none': 'Keine Packliste verknüpft',
'tours.packing.link': 'Packliste verknüpfen',
'tours.packing.change': 'Ändern',
'tours.packing.whole': 'Ganze Packliste',
'tours.packing.packed': '{packed} von {count} gepackt',
'tours.packing.packed.one': '{packed} von {count} gepackt',
'tours.packing.more': '+{count} weitere',
'tours.packing.more.one': '+{count} weiteres',
'tours.packing.items': '{count} Gegenstände',
'tours.packing.items.one': '{count} Gegenstand',
'tours.packing.allPacked': 'Alles gepackt',
'tours.packing.open': 'Packliste öffnen',
'tours.packing.missing': '„{name}“ hat keine Gegenstände mehr, die du sehen kannst.',
'tours.packing.emptyList': 'Die Packliste ist noch leer.',
'tours.packing.emptyCategory': '„{name}“ hat noch keine Gegenstände.',
'tours.packing.addonOff': 'Packlisten sind ausgeschaltet, deshalb kann die Liste nicht angezeigt werden.',
'tours.packing.loadFailed': 'Die Verknüpfung zur Packliste konnte nicht geladen werden.',
'tours.packing.dialogSub': 'Nur eine Verknüpfung. Die Liste und ihre Gegenstände bleiben, wo sie sind.',
'tours.packing.choose': 'Verknüpfen mit',
'tours.packing.confirm': 'Verknüpfen',
'tours.packing.unlink': 'Verknüpfung lösen',
'tours.packing.unlinkHint': 'Die Packliste und ihre Gegenstände bleiben unverändert',
'tours.packing.linked': 'Packliste verknüpft.',
'tours.packing.unlinked': 'Verknüpfung gelöst. Die Packliste bleibt unverändert.',
'tours.packing.failed': 'Die Verknüpfung konnte nicht gespeichert werden.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Packing list',
'tours.packing.none': 'No packing list linked',
'tours.packing.link': 'Link a packing list',
'tours.packing.change': 'Change',
'tours.packing.whole': 'Whole packing list',
'tours.packing.packed': '{packed} of {count} packed',
'tours.packing.packed.one': '{packed} of {count} packed',
'tours.packing.more': '+{count} more',
'tours.packing.more.one': '+{count} more',
'tours.packing.items': '{count} items',
'tours.packing.items.one': '{count} item',
'tours.packing.allPacked': 'All packed',
'tours.packing.open': 'Open packing list',
'tours.packing.missing': '“{name}” has no items you can see any more.',
'tours.packing.emptyList': 'The packing list is still empty.',
'tours.packing.emptyCategory': '“{name}” has no items yet.',
'tours.packing.addonOff': 'Packing lists are switched off, so the list cannot be shown.',
'tours.packing.loadFailed': 'The packing link could not be loaded.',
'tours.packing.dialogSub': 'Only a link. The list and its items stay where they are.',
'tours.packing.choose': 'Link to',
'tours.packing.confirm': 'Link',
'tours.packing.unlink': 'Remove link',
'tours.packing.unlinkHint': 'The packing list and its items stay as they are',
'tours.packing.linked': 'Packing list linked.',
'tours.packing.unlinked': 'Link removed. The packing list stays as it is.',
'tours.packing.failed': 'The link could not be saved.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Lista de equipaje',
'tours.packing.none': 'Ninguna lista de equipaje vinculada',
'tours.packing.link': 'Vincular una lista de equipaje',
'tours.packing.change': 'Cambiar',
'tours.packing.whole': 'Toda la lista de equipaje',
'tours.packing.packed': '{packed} de {count} empacados',
'tours.packing.packed.one': '{packed} de {count} empacado',
'tours.packing.more': '+{count} más',
'tours.packing.more.one': '+{count} más',
'tours.packing.items': '{count} artículos',
'tours.packing.items.one': '{count} artículo',
'tours.packing.allPacked': 'Todo empacado',
'tours.packing.open': 'Abrir la lista de equipaje',
'tours.packing.missing': '«{name}» ya no tiene artículos que puedas ver.',
'tours.packing.emptyList': 'La lista de equipaje aún está vacía.',
'tours.packing.emptyCategory': '«{name}» aún no tiene artículos.',
'tours.packing.addonOff': 'Las listas de equipaje están desactivadas, así que la lista no se puede mostrar.',
'tours.packing.loadFailed': 'No se pudo cargar el vínculo con la lista de equipaje.',
'tours.packing.dialogSub': 'Solo un vínculo. La lista y sus artículos se quedan donde están.',
'tours.packing.choose': 'Vincular con',
'tours.packing.confirm': 'Vincular',
'tours.packing.unlink': 'Quitar vínculo',
'tours.packing.unlinkHint': 'La lista de equipaje y sus artículos quedan como están',
'tours.packing.linked': 'Lista de equipaje vinculada.',
'tours.packing.unlinked': 'Vínculo quitado. La lista de equipaje queda como está.',
'tours.packing.failed': 'No se pudo guardar el vínculo.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -73,6 +74,7 @@ const locale = {
...trip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...places,
...inspector,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Pakkimisnimekiri',
'tours.packing.none': 'Pakkimisnimekirja pole lingitud',
'tours.packing.link': 'Lingi pakkimisnimekiri',
'tours.packing.change': 'Muuda',
'tours.packing.whole': 'Kogu pakkimisnimekiri',
'tours.packing.packed': 'Pakitud {packed} / {count}',
'tours.packing.packed.one': 'Pakitud {packed} / {count}',
'tours.packing.more': 'Veel {count}',
'tours.packing.more.one': 'Veel {count}',
'tours.packing.items': '{count} asja',
'tours.packing.items.one': '{count} asi',
'tours.packing.allPacked': 'Kõik pakitud',
'tours.packing.open': 'Ava pakkimisnimekiri',
'tours.packing.missing': '„{name}“ juures pole enam ühtegi asja, mida sa näed.',
'tours.packing.emptyList': 'Pakkimisnimekiri on veel tühi.',
'tours.packing.emptyCategory': '„{name}“ juures pole veel ühtegi asja.',
'tours.packing.addonOff': 'Pakkimisnimekirjad on välja lülitatud, seega nimekirja ei saa näidata.',
'tours.packing.loadFailed': 'Pakkimisnimekirja linki ei õnnestunud laadida.',
'tours.packing.dialogSub': 'Ainult link. Nimekiri ja selle asjad jäävad oma kohale.',
'tours.packing.choose': 'Lingi sihtkoht',
'tours.packing.confirm': 'Lingi',
'tours.packing.unlink': 'Eemalda link',
'tours.packing.unlinkHint': 'Pakkimisnimekiri ja selle asjad jäävad samaks',
'tours.packing.linked': 'Pakkimisnimekiri on lingitud.',
'tours.packing.unlinked': 'Link on eemaldatud. Pakkimisnimekiri jääb samaks.',
'tours.packing.failed': 'Linki ei õnnestunud salvestada.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Liste de bagages',
'tours.packing.none': 'Aucune liste de bagages liée',
'tours.packing.link': 'Lier une liste de bagages',
'tours.packing.change': 'Modifier',
'tours.packing.whole': 'Toute la liste de bagages',
'tours.packing.packed': '{packed} sur {count} emballés',
'tours.packing.packed.one': '{packed} sur {count} emballé',
'tours.packing.more': '+{count} autres',
'tours.packing.more.one': '+{count} autre',
'tours.packing.items': '{count} articles',
'tours.packing.items.one': '{count} article',
'tours.packing.allPacked': 'Tout est emballé',
'tours.packing.open': 'Ouvrir la liste de bagages',
'tours.packing.missing': '« {name} » n’a plus d’article visible pour vous.',
'tours.packing.emptyList': 'La liste de bagages est encore vide.',
'tours.packing.emptyCategory': '« {name} » n’a encore aucun article.',
'tours.packing.addonOff': 'Les listes de bagages sont désactivées, la liste ne peut donc pas être affichée.',
'tours.packing.loadFailed': 'Le lien vers la liste de bagages n’a pas pu être chargé.',
'tours.packing.dialogSub': 'Un simple lien. La liste et ses articles restent où ils sont.',
'tours.packing.choose': 'Lier à',
'tours.packing.confirm': 'Lier',
'tours.packing.unlink': 'Retirer le lien',
'tours.packing.unlinkHint': 'La liste de bagages et ses articles restent tels quels',
'tours.packing.linked': 'Liste de bagages liée.',
'tours.packing.unlinked': 'Lien retiré. La liste de bagages reste telle quelle.',
'tours.packing.failed': 'Le lien n’a pas pu être enregistré.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Λίστα αποσκευών',
'tours.packing.none': 'Δεν έχει συνδεθεί λίστα αποσκευών',
'tours.packing.link': 'Σύνδεση λίστας αποσκευών',
'tours.packing.change': 'Αλλαγή',
'tours.packing.whole': 'Όλη η λίστα αποσκευών',
'tours.packing.packed': '{packed} από {count} στη βαλίτσα',
'tours.packing.packed.one': '{packed} από {count} στη βαλίτσα',
'tours.packing.more': '+{count} ακόμη',
'tours.packing.more.one': '+{count} ακόμη',
'tours.packing.items': '{count} αντικείμενα',
'tours.packing.items.one': '{count} αντικείμενο',
'tours.packing.allPacked': 'Όλα στη βαλίτσα',
'tours.packing.open': 'Άνοιγμα λίστας αποσκευών',
'tours.packing.missing': 'Το «{name}» δεν έχει πια αντικείμενα που μπορείτε να δείτε.',
'tours.packing.emptyList': 'Η λίστα αποσκευών είναι ακόμη άδεια.',
'tours.packing.emptyCategory': 'Το «{name}» δεν έχει ακόμη αντικείμενα.',
'tours.packing.addonOff': 'Οι λίστες αποσκευών είναι απενεργοποιημένες, γι’ αυτό η λίστα δεν μπορεί να εμφανιστεί.',
'tours.packing.loadFailed': 'Η σύνδεση με τη λίστα αποσκευών δεν φορτώθηκε.',
'tours.packing.dialogSub': 'Μόνο μια σύνδεση. Η λίστα και τα αντικείμενά της μένουν εκεί που είναι.',
'tours.packing.choose': 'Σύνδεση με',
'tours.packing.confirm': 'Σύνδεση',
'tours.packing.unlink': 'Αφαίρεση σύνδεσης',
'tours.packing.unlinkHint': 'Η λίστα αποσκευών και τα αντικείμενά της μένουν ως έχουν',
'tours.packing.linked': 'Η λίστα αποσκευών συνδέθηκε.',
'tours.packing.unlinked': 'Η σύνδεση αφαιρέθηκε. Η λίστα αποσκευών μένει ως έχει.',
'tours.packing.failed': 'Η σύνδεση δεν αποθηκεύτηκε.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Csomagolási lista',
'tours.packing.none': 'Nincs csatolt csomagolási lista',
'tours.packing.link': 'Csomagolási lista csatolása',
'tours.packing.change': 'Módosítás',
'tours.packing.whole': 'A teljes csomagolási lista',
'tours.packing.packed': '{packed}/{count} becsomagolva',
'tours.packing.packed.one': '{packed}/{count} becsomagolva',
'tours.packing.more': 'További {count}',
'tours.packing.more.one': 'További {count}',
'tours.packing.items': '{count} tétel',
'tours.packing.items.one': '{count} tétel',
'tours.packing.allPacked': 'Minden becsomagolva',
'tours.packing.open': 'Csomagolási lista megnyitása',
'tours.packing.missing': 'A(z) „{name}” már nem tartalmaz látható tételt.',
'tours.packing.emptyList': 'A csomagolási lista még üres.',
'tours.packing.emptyCategory': 'A(z) „{name}” még nem tartalmaz tételt.',
'tours.packing.addonOff': 'A csomagolási listák ki vannak kapcsolva, ezért a lista nem jeleníthető meg.',
'tours.packing.loadFailed': 'A csomagolási lista csatolását nem sikerült betölteni.',
'tours.packing.dialogSub': 'Csak egy csatolás. A lista és a tételei maradnak, ahol vannak.',
'tours.packing.choose': 'Csatolás ehhez',
'tours.packing.confirm': 'Csatolás',
'tours.packing.unlink': 'Csatolás megszüntetése',
'tours.packing.unlinkHint': 'A csomagolási lista és a tételei változatlanok maradnak',
'tours.packing.linked': 'Csomagolási lista csatolva.',
'tours.packing.unlinked': 'Csatolás megszüntetve. A csomagolási lista változatlan marad.',
'tours.packing.failed': 'A csatolást nem sikerült menteni.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Daftar bawaan',
'tours.packing.none': 'Belum ada daftar bawaan yang ditautkan',
'tours.packing.link': 'Tautkan daftar bawaan',
'tours.packing.change': 'Ubah',
'tours.packing.whole': 'Seluruh daftar bawaan',
'tours.packing.packed': '{packed} dari {count} sudah dikemas',
'tours.packing.more': '+{count} lagi',
'tours.packing.items': '{count} barang',
'tours.packing.allPacked': 'Semua sudah dikemas',
'tours.packing.open': 'Buka daftar bawaan',
'tours.packing.missing': '“{name}” tidak lagi berisi barang yang bisa kamu lihat.',
'tours.packing.emptyList': 'Daftar bawaan masih kosong.',
'tours.packing.emptyCategory': '“{name}” belum berisi barang.',
'tours.packing.addonOff': 'Daftar bawaan dinonaktifkan, jadi daftar ini tidak bisa ditampilkan.',
'tours.packing.loadFailed': 'Tautan daftar bawaan tidak dapat dimuat.',
'tours.packing.dialogSub': 'Hanya tautan. Daftar dan barangnya tetap di tempatnya.',
'tours.packing.choose': 'Tautkan ke',
'tours.packing.confirm': 'Tautkan',
'tours.packing.unlink': 'Hapus tautan',
'tours.packing.unlinkHint': 'Daftar bawaan dan barangnya tetap seperti semula',
'tours.packing.linked': 'Daftar bawaan ditautkan.',
'tours.packing.unlinked': 'Tautan dihapus. Daftar bawaan tetap seperti semula.',
'tours.packing.failed': 'Tautan tidak dapat disimpan.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Lista valigia',
'tours.packing.none': 'Nessuna lista valigia collegata',
'tours.packing.link': 'Collega una lista valigia',
'tours.packing.change': 'Cambia',
'tours.packing.whole': 'Intera lista valigia',
'tours.packing.packed': '{packed} di {count} in valigia',
'tours.packing.packed.one': '{packed} di {count} in valigia',
'tours.packing.more': '+{count} altri',
'tours.packing.more.one': '+{count} altro',
'tours.packing.items': '{count} oggetti',
'tours.packing.items.one': '{count} oggetto',
'tours.packing.allPacked': 'Tutto in valigia',
'tours.packing.open': 'Apri la lista valigia',
'tours.packing.missing': '“{name}” non ha più oggetti che puoi vedere.',
'tours.packing.emptyList': 'La lista valigia è ancora vuota.',
'tours.packing.emptyCategory': '“{name}” non ha ancora oggetti.',
'tours.packing.addonOff': 'Le liste valigia sono disattivate, quindi la lista non può essere mostrata.',
'tours.packing.loadFailed': 'Non è stato possibile caricare il collegamento alla lista valigia.',
'tours.packing.dialogSub': 'Solo un collegamento. La lista e i suoi oggetti restano dove sono.',
'tours.packing.choose': 'Collega a',
'tours.packing.confirm': 'Collega',
'tours.packing.unlink': 'Rimuovi collegamento',
'tours.packing.unlinkHint': 'La lista valigia e i suoi oggetti restano come sono',
'tours.packing.linked': 'Lista valigia collegata.',
'tours.packing.unlinked': 'Collegamento rimosso. La lista valigia resta com’è.',
'tours.packing.failed': 'Non è stato possibile salvare il collegamento.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': '持ち物リスト',
'tours.packing.none': '持ち物リストはリンクされていません',
'tours.packing.link': '持ち物リストをリンク',
'tours.packing.change': '変更',
'tours.packing.whole': '持ち物リスト全体',
'tours.packing.packed': '{count} 件中 {packed} 件準備済み',
'tours.packing.more': 'ほか {count} 件',
'tours.packing.items': '{count} 件',
'tours.packing.allPacked': 'すべて準備済み',
'tours.packing.open': '持ち物リストを開く',
'tours.packing.missing': '「{name}」には、表示できるアイテムがもうありません。',
'tours.packing.emptyList': '持ち物リストはまだ空です。',
'tours.packing.emptyCategory': '「{name}」にはまだアイテムがありません。',
'tours.packing.addonOff': '持ち物リストがオフのため、リストを表示できません。',
'tours.packing.loadFailed': '持ち物リストのリンクを読み込めませんでした。',
'tours.packing.dialogSub': 'リンクするだけです。リストとアイテムはそのまま残ります。',
'tours.packing.choose': 'リンク先',
'tours.packing.confirm': 'リンク',
'tours.packing.unlink': 'リンクを解除',
'tours.packing.unlinkHint': '持ち物リストとアイテムはそのまま残ります',
'tours.packing.linked': '持ち物リストをリンクしました。',
'tours.packing.unlinked': 'リンクを解除しました。持ち物リストはそのままです。',
'tours.packing.failed': 'リンクを保存できませんでした。',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': '짐 목록',
'tours.packing.none': '연결된 짐 목록 없음',
'tours.packing.link': '짐 목록 연결',
'tours.packing.change': '변경',
'tours.packing.whole': '짐 목록 전체',
'tours.packing.packed': '{count}개 중 {packed}개 챙김',
'tours.packing.more': '{count}개 더',
'tours.packing.items': '{count}개',
'tours.packing.allPacked': '모두 챙김',
'tours.packing.open': '짐 목록 열기',
'tours.packing.missing': '“{name}”에 더 이상 볼 수 있는 물품이 없습니다.',
'tours.packing.emptyList': '짐 목록이 아직 비어 있습니다.',
'tours.packing.emptyCategory': '“{name}”에 아직 물품이 없습니다.',
'tours.packing.addonOff': '짐 목록이 꺼져 있어 목록을 표시할 수 없습니다.',
'tours.packing.loadFailed': '짐 목록 연결을 불러오지 못했습니다.',
'tours.packing.dialogSub': '연결만 합니다. 목록과 물품은 그대로 남습니다.',
'tours.packing.choose': '연결 대상',
'tours.packing.confirm': '연결',
'tours.packing.unlink': '연결 해제',
'tours.packing.unlinkHint': '짐 목록과 물품은 그대로 유지됩니다',
'tours.packing.linked': '짐 목록을 연결했습니다.',
'tours.packing.unlinked': '연결을 해제했습니다. 짐 목록은 그대로입니다.',
'tours.packing.failed': '연결을 저장하지 못했습니다.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Inpaklijst',
'tours.packing.none': 'Geen inpaklijst gekoppeld',
'tours.packing.link': 'Inpaklijst koppelen',
'tours.packing.change': 'Wijzigen',
'tours.packing.whole': 'Hele inpaklijst',
'tours.packing.packed': '{packed} van {count} ingepakt',
'tours.packing.packed.one': '{packed} van {count} ingepakt',
'tours.packing.more': '+{count} meer',
'tours.packing.more.one': '+{count} meer',
'tours.packing.items': '{count} artikelen',
'tours.packing.items.one': '{count} artikel',
'tours.packing.allPacked': 'Alles ingepakt',
'tours.packing.open': 'Inpaklijst openen',
'tours.packing.missing': '‘{name}’ heeft geen spullen meer die je kunt zien.',
'tours.packing.emptyList': 'De inpaklijst is nog leeg.',
'tours.packing.emptyCategory': '‘{name}’ heeft nog geen spullen.',
'tours.packing.addonOff': 'Inpaklijsten zijn uitgeschakeld, dus de lijst kan niet worden getoond.',
'tours.packing.loadFailed': 'De koppeling met de inpaklijst kon niet worden geladen.',
'tours.packing.dialogSub': 'Alleen een koppeling. De lijst en de spullen erin blijven waar ze zijn.',
'tours.packing.choose': 'Koppelen aan',
'tours.packing.confirm': 'Koppelen',
'tours.packing.unlink': 'Koppeling verwijderen',
'tours.packing.unlinkHint': 'De inpaklijst en de spullen erin blijven zoals ze zijn',
'tours.packing.linked': 'Inpaklijst gekoppeld.',
'tours.packing.unlinked': 'Koppeling verwijderd. De inpaklijst blijft zoals hij is.',
'tours.packing.failed': 'De koppeling kon niet worden opgeslagen.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+37
View File
@@ -0,0 +1,37 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Lista pakowania',
'tours.packing.none': 'Brak połączonej listy pakowania',
'tours.packing.link': 'Połącz listę pakowania',
'tours.packing.change': 'Zmień',
'tours.packing.whole': 'Cała lista pakowania',
'tours.packing.packed': 'Spakowano {packed} z {count}',
'tours.packing.packed.one': 'Spakowano {packed} z {count}',
'tours.packing.packed.few': 'Spakowano {packed} z {count}',
'tours.packing.packed.many': 'Spakowano {packed} z {count}',
'tours.packing.more': '+{count} więcej',
'tours.packing.more.one': '+{count} więcej',
'tours.packing.more.few': '+{count} więcej',
'tours.packing.more.many': '+{count} więcej',
'tours.packing.items': '{count} rzeczy',
'tours.packing.items.one': '{count} rzecz',
'tours.packing.items.few': '{count} rzeczy',
'tours.packing.items.many': '{count} rzeczy',
'tours.packing.allPacked': 'Wszystko spakowane',
'tours.packing.open': 'Otwórz listę pakowania',
'tours.packing.missing': '„{name}” nie ma już rzeczy, które możesz zobaczyć.',
'tours.packing.emptyList': 'Lista pakowania jest jeszcze pusta.',
'tours.packing.emptyCategory': '„{name}” nie ma jeszcze rzeczy.',
'tours.packing.addonOff': 'Listy pakowania są wyłączone, więc nie można pokazać listy.',
'tours.packing.loadFailed': 'Nie udało się wczytać połączenia z listą pakowania.',
'tours.packing.dialogSub': 'Tylko połączenie. Lista i jej rzeczy zostają tam, gdzie są.',
'tours.packing.choose': 'Połącz z',
'tours.packing.confirm': 'Połącz',
'tours.packing.unlink': 'Usuń połączenie',
'tours.packing.unlinkHint': 'Lista pakowania i jej rzeczy zostają bez zmian',
'tours.packing.linked': 'Lista pakowania połączona.',
'tours.packing.unlinked': 'Połączenie usunięte. Lista pakowania zostaje bez zmian.',
'tours.packing.failed': 'Nie udało się zapisać połączenia.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+37
View File
@@ -0,0 +1,37 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Список вещей',
'tours.packing.none': 'Список вещей не привязан',
'tours.packing.link': 'Привязать список вещей',
'tours.packing.change': 'Изменить',
'tours.packing.whole': 'Весь список вещей',
'tours.packing.packed': 'Упаковано {packed} из {count}',
'tours.packing.packed.one': 'Упаковано {packed} из {count}',
'tours.packing.packed.few': 'Упаковано {packed} из {count}',
'tours.packing.packed.many': 'Упаковано {packed} из {count}',
'tours.packing.more': 'Ещё {count}',
'tours.packing.more.one': 'Ещё {count}',
'tours.packing.more.few': 'Ещё {count}',
'tours.packing.more.many': 'Ещё {count}',
'tours.packing.items': 'Вещей: {count}',
'tours.packing.items.one': '{count} вещь',
'tours.packing.items.few': '{count} вещи',
'tours.packing.items.many': '{count} вещей',
'tours.packing.allPacked': 'Всё упаковано',
'tours.packing.open': 'Открыть список вещей',
'tours.packing.missing': 'В «{name}» больше нет вещей, которые вы можете видеть.',
'tours.packing.emptyList': 'Список вещей пока пуст.',
'tours.packing.emptyCategory': 'В «{name}» пока нет вещей.',
'tours.packing.addonOff': 'Списки вещей отключены, поэтому список нельзя показать.',
'tours.packing.loadFailed': 'Не удалось загрузить привязку к списку вещей.',
'tours.packing.dialogSub': 'Только привязка. Список и его вещи остаются на месте.',
'tours.packing.choose': 'Привязать к',
'tours.packing.confirm': 'Привязать',
'tours.packing.unlink': 'Отвязать',
'tours.packing.unlinkHint': 'Список вещей и его вещи останутся без изменений',
'tours.packing.linked': 'Список вещей привязан.',
'tours.packing.unlinked': 'Привязка снята. Список вещей остаётся без изменений.',
'tours.packing.failed': 'Не удалось сохранить привязку.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -73,6 +74,7 @@ const locale = {
...trip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...places,
...inspector,
+34
View File
@@ -0,0 +1,34 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Zoznam vecí',
'tours.packing.none': 'Žiadny prepojený zoznam vecí',
'tours.packing.link': 'Prepojiť zoznam vecí',
'tours.packing.change': 'Zmeniť',
'tours.packing.whole': 'Celý zoznam vecí',
'tours.packing.packed': 'Zbalené {packed} z {count}',
'tours.packing.packed.one': 'Zbalené {packed} z {count}',
'tours.packing.packed.few': 'Zbalené {packed} z {count}',
'tours.packing.more': '+{count} ďalších',
'tours.packing.more.one': '+{count} ďalšia',
'tours.packing.more.few': '+{count} ďalšie',
'tours.packing.items': '{count} vecí',
'tours.packing.items.one': '{count} vec',
'tours.packing.items.few': '{count} veci',
'tours.packing.allPacked': 'Všetko zbalené',
'tours.packing.open': 'Otvoriť zoznam vecí',
'tours.packing.missing': '„{name}“ už nemá žiadne veci, ktoré môžete vidieť.',
'tours.packing.emptyList': 'Zoznam vecí je zatiaľ prázdny.',
'tours.packing.emptyCategory': '„{name}“ zatiaľ nemá žiadne veci.',
'tours.packing.addonOff': 'Zoznamy vecí sú vypnuté, preto zoznam nemožno zobraziť.',
'tours.packing.loadFailed': 'Prepojenie so zoznamom vecí sa nepodarilo načítať.',
'tours.packing.dialogSub': 'Len prepojenie. Zoznam a jeho veci zostávajú, kde sú.',
'tours.packing.choose': 'Prepojiť s',
'tours.packing.confirm': 'Prepojiť',
'tours.packing.unlink': 'Zrušiť prepojenie',
'tours.packing.unlinkHint': 'Zoznam vecí a jeho veci zostanú bez zmeny',
'tours.packing.linked': 'Zoznam vecí je prepojený.',
'tours.packing.unlinked': 'Prepojenie zrušené. Zoznam vecí zostáva bez zmeny.',
'tours.packing.failed': 'Prepojenie sa nepodarilo uložiť.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Packlista',
'tours.packing.none': 'Ingen packlista kopplad',
'tours.packing.link': 'Koppla en packlista',
'tours.packing.change': 'Ändra',
'tours.packing.whole': 'Hela packlistan',
'tours.packing.packed': '{packed} av {count} packade',
'tours.packing.packed.one': '{packed} av {count} packad',
'tours.packing.more': '+{count} till',
'tours.packing.more.one': '+{count} till',
'tours.packing.items': '{count} saker',
'tours.packing.items.one': '{count} sak',
'tours.packing.allPacked': 'Allt packat',
'tours.packing.open': 'Öppna packlistan',
'tours.packing.missing': '”{name}” har inga saker kvar som du kan se.',
'tours.packing.emptyList': 'Packlistan är fortfarande tom.',
'tours.packing.emptyCategory': '”{name}” har inga saker än.',
'tours.packing.addonOff': 'Packlistor är avstängda, så listan kan inte visas.',
'tours.packing.loadFailed': 'Kopplingen till packlistan kunde inte läsas in.',
'tours.packing.dialogSub': 'Bara en koppling. Listan och sakerna i den stannar där de är.',
'tours.packing.choose': 'Koppla till',
'tours.packing.confirm': 'Koppla',
'tours.packing.unlink': 'Ta bort koppling',
'tours.packing.unlinkHint': 'Packlistan och sakerna i den förblir som de är',
'tours.packing.linked': 'Packlista kopplad.',
'tours.packing.unlinked': 'Kopplingen är borttagen. Packlistan förblir som den är.',
'tours.packing.failed': 'Kopplingen kunde inte sparas.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -73,6 +74,7 @@ const locale = {
...trip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...places,
...inspector,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'รายการจัดกระเป๋า',
'tours.packing.none': 'ยังไม่ได้ลิงก์รายการจัดกระเป๋า',
'tours.packing.link': 'ลิงก์รายการจัดกระเป๋า',
'tours.packing.change': 'เปลี่ยน',
'tours.packing.whole': 'รายการจัดกระเป๋าทั้งหมด',
'tours.packing.packed': 'จัดแล้ว {packed} จาก {count}',
'tours.packing.more': 'อีก {count} รายการ',
'tours.packing.items': '{count} รายการ',
'tours.packing.allPacked': 'จัดครบแล้ว',
'tours.packing.open': 'เปิดรายการจัดกระเป๋า',
'tours.packing.missing': '“{name}” ไม่มีของที่คุณมองเห็นได้อีกแล้ว',
'tours.packing.emptyList': 'รายการจัดกระเป๋ายังว่างอยู่',
'tours.packing.emptyCategory': '“{name}” ยังไม่มีของ',
'tours.packing.addonOff': 'รายการจัดกระเป๋าถูกปิดอยู่ จึงแสดงรายการไม่ได้',
'tours.packing.loadFailed': 'โหลดลิงก์รายการจัดกระเป๋าไม่ได้',
'tours.packing.dialogSub': 'เป็นเพียงลิงก์ รายการและของในรายการยังอยู่ที่เดิม',
'tours.packing.choose': 'ลิงก์ไปยัง',
'tours.packing.confirm': 'ลิงก์',
'tours.packing.unlink': 'เลิกลิงก์',
'tours.packing.unlinkHint': 'รายการจัดกระเป๋าและของในรายการยังคงเหมือนเดิม',
'tours.packing.linked': 'ลิงก์รายการจัดกระเป๋าแล้ว',
'tours.packing.unlinked': 'เลิกลิงก์แล้ว รายการจัดกระเป๋ายังคงเหมือนเดิม',
'tours.packing.failed': 'บันทึกลิงก์ไม่ได้',
};
export default tourPacking;
+8 -3
View File
@@ -14,9 +14,9 @@ type DomainModules = Record<string, { default: TranslationStrings }>;
const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tours.ts', { eager: true });
// The tours strings live in more than one file per locale, so that none of
// them grows past the size limit: `tours.ts` holds the planner, and
// `tourCollection.ts` the tours kept in a collection. Each file is held to
// its own count.
// them grows past the size limit: `tours.ts` holds the planner,
// `tourCollection.ts` the tours kept in a collection and `tourPacking.ts` the
// packing list a tour points at. Each file is held to its own count.
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
{
file: 'tours',
@@ -28,6 +28,11 @@ const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModu
generalKeyCount: 41,
modules: import.meta.glob<{ default: TranslationStrings }>('./*/tourCollection.ts', { eager: true }),
},
{
file: 'tourPacking',
generalKeyCount: 23,
modules: import.meta.glob<{ default: TranslationStrings }>('./*/tourPacking.ts', { eager: true }),
},
];
const aggregates = import.meta.glob<{ default: TranslationStrings }>('./*/index.ts', { eager: true });
const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) => match[0]).sort();
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Paket listesi',
'tours.packing.none': 'Bağlı paket listesi yok',
'tours.packing.link': 'Paket listesi bağla',
'tours.packing.change': 'Değiştir',
'tours.packing.whole': 'Tüm paket listesi',
'tours.packing.packed': '{count} eşyadan {packed} tanesi paketlendi',
'tours.packing.packed.one': '{count} eşyadan {packed} tanesi paketlendi',
'tours.packing.more': '{count} tane daha',
'tours.packing.more.one': '{count} tane daha',
'tours.packing.items': '{count} eşya',
'tours.packing.items.one': '{count} eşya',
'tours.packing.allPacked': 'Hepsi paketlendi',
'tours.packing.open': 'Paket listesini aç',
'tours.packing.missing': '“{name}” içinde artık görebileceğiniz eşya yok.',
'tours.packing.emptyList': 'Paket listesi henüz boş.',
'tours.packing.emptyCategory': '“{name}” içinde henüz eşya yok.',
'tours.packing.addonOff': 'Paket listeleri kapalı, bu yüzden liste gösterilemiyor.',
'tours.packing.loadFailed': 'Paket listesi bağlantısı yüklenemedi.',
'tours.packing.dialogSub': 'Yalnızca bir bağlantı. Liste ve eşyaları olduğu yerde kalır.',
'tours.packing.choose': 'Bağlanacak yer',
'tours.packing.confirm': 'Bağla',
'tours.packing.unlink': 'Bağlantıyı kaldır',
'tours.packing.unlinkHint': 'Paket listesi ve eşyaları olduğu gibi kalır',
'tours.packing.linked': 'Paket listesi bağlandı.',
'tours.packing.unlinked': 'Bağlantı kaldırıldı. Paket listesi olduğu gibi kalır.',
'tours.packing.failed': 'Bağlantı kaydedilemedi.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+37
View File
@@ -0,0 +1,37 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Список речей',
'tours.packing.none': 'Список речей не прив’язано',
'tours.packing.link': 'Прив’язати список речей',
'tours.packing.change': 'Змінити',
'tours.packing.whole': 'Увесь список речей',
'tours.packing.packed': 'Спаковано {packed} з {count}',
'tours.packing.packed.one': 'Спаковано {packed} з {count}',
'tours.packing.packed.few': 'Спаковано {packed} з {count}',
'tours.packing.packed.many': 'Спаковано {packed} з {count}',
'tours.packing.more': 'Ще {count}',
'tours.packing.more.one': 'Ще {count}',
'tours.packing.more.few': 'Ще {count}',
'tours.packing.more.many': 'Ще {count}',
'tours.packing.items': 'Речей: {count}',
'tours.packing.items.one': '{count} річ',
'tours.packing.items.few': '{count} речі',
'tours.packing.items.many': '{count} речей',
'tours.packing.allPacked': 'Усе спаковано',
'tours.packing.open': 'Відкрити список речей',
'tours.packing.missing': 'У «{name}» більше немає речей, які ви можете бачити.',
'tours.packing.emptyList': 'Список речей поки порожній.',
'tours.packing.emptyCategory': 'У «{name}» поки немає речей.',
'tours.packing.addonOff': 'Списки речей вимкнено, тому список неможливо показати.',
'tours.packing.loadFailed': 'Не вдалося завантажити прив’язку до списку речей.',
'tours.packing.dialogSub': 'Лише прив’язка. Список і його речі залишаються на місці.',
'tours.packing.choose': 'Прив’язати до',
'tours.packing.confirm': 'Прив’язати',
'tours.packing.unlink': 'Відв’язати',
'tours.packing.unlinkHint': 'Список речей і його речі залишаться без змін',
'tours.packing.linked': 'Список речей прив’язано.',
'tours.packing.unlinked': 'Прив’язку знято. Список речей залишається без змін.',
'tours.packing.failed': 'Не вдалося зберегти прив’язку.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': 'Danh sách đóng gói',
'tours.packing.none': 'Chưa liên kết danh sách đóng gói',
'tours.packing.link': 'Liên kết danh sách đóng gói',
'tours.packing.change': 'Thay đổi',
'tours.packing.whole': 'Toàn bộ danh sách đóng gói',
'tours.packing.packed': 'Đã đóng gói {packed}/{count}',
'tours.packing.more': '+{count} món nữa',
'tours.packing.items': '{count} món',
'tours.packing.allPacked': 'Đã đóng gói hết',
'tours.packing.open': 'Mở danh sách đóng gói',
'tours.packing.missing': '“{name}” không còn món nào bạn có thể xem.',
'tours.packing.emptyList': 'Danh sách đóng gói vẫn còn trống.',
'tours.packing.emptyCategory': '“{name}” chưa có món nào.',
'tours.packing.addonOff': 'Danh sách đóng gói đang tắt nên không thể hiển thị danh sách.',
'tours.packing.loadFailed': 'Không tải được liên kết danh sách đóng gói.',
'tours.packing.dialogSub': 'Chỉ là liên kết. Danh sách và các món trong đó vẫn ở nguyên chỗ.',
'tours.packing.choose': 'Liên kết với',
'tours.packing.confirm': 'Liên kết',
'tours.packing.unlink': 'Gỡ liên kết',
'tours.packing.unlinkHint': 'Danh sách đóng gói và các món trong đó giữ nguyên',
'tours.packing.linked': 'Đã liên kết danh sách đóng gói.',
'tours.packing.unlinked': 'Đã gỡ liên kết. Danh sách đóng gói vẫn giữ nguyên.',
'tours.packing.failed': 'Không lưu được liên kết.',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': '行李清單',
'tours.packing.none': '未連結行李清單',
'tours.packing.link': '連結行李清單',
'tours.packing.change': '變更',
'tours.packing.whole': '整份行李清單',
'tours.packing.packed': '已打包 {packed}/{count}',
'tours.packing.more': '還有 {count} 項',
'tours.packing.items': '{count} 項',
'tours.packing.allPacked': '全部已打包',
'tours.packing.open': '開啟行李清單',
'tours.packing.missing': '「{name}」中已沒有你能看到的物品。',
'tours.packing.emptyList': '行李清單還是空的。',
'tours.packing.emptyCategory': '「{name}」中還沒有物品。',
'tours.packing.addonOff': '行李清單已關閉,因此無法顯示該清單。',
'tours.packing.loadFailed': '無法載入行李清單連結。',
'tours.packing.dialogSub': '只是連結。清單及其物品保持不變。',
'tours.packing.choose': '連結到',
'tours.packing.confirm': '連結',
'tours.packing.unlink': '取消連結',
'tours.packing.unlinkHint': '行李清單及其物品保持不變',
'tours.packing.linked': '已連結行李清單。',
'tours.packing.unlinked': '已取消連結。行李清單保持不變。',
'tours.packing.failed': '無法儲存連結。',
};
export default tourPacking;
+2
View File
@@ -47,6 +47,7 @@ import storage from './storage';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
import tourPacking from './tourPacking';
import tours from './tours';
import transport from './transport';
import trip from './trip';
@@ -63,6 +64,7 @@ const locale = {
...roadtrip,
...tours,
...tourCollection,
...tourPacking,
...wanderer,
...settings,
...admin,
+28
View File
@@ -0,0 +1,28 @@
import type { TranslationStrings } from '../types';
const tourPacking: TranslationStrings = {
'tours.packing.label': '行李清单',
'tours.packing.none': '未关联行李清单',
'tours.packing.link': '关联行李清单',
'tours.packing.change': '更改',
'tours.packing.whole': '整个行李清单',
'tours.packing.packed': '已打包 {packed}/{count}',
'tours.packing.more': '还有 {count} 项',
'tours.packing.items': '{count} 项',
'tours.packing.allPacked': '全部已打包',
'tours.packing.open': '打开行李清单',
'tours.packing.missing': '“{name}”中已没有你能看到的物品。',
'tours.packing.emptyList': '行李清单还是空的。',
'tours.packing.emptyCategory': '“{name}”中还没有物品。',
'tours.packing.addonOff': '行李清单已关闭,因此无法显示该清单。',
'tours.packing.loadFailed': '无法加载行李清单关联。',
'tours.packing.dialogSub': '只是关联。清单及其物品保持不变。',
'tours.packing.choose': '关联到',
'tours.packing.confirm': '关联',
'tours.packing.unlink': '取消关联',
'tours.packing.unlinkHint': '行李清单及其物品保持不变',
'tours.packing.linked': '已关联行李清单。',
'tours.packing.unlinked': '已取消关联。行李清单保持不变。',
'tours.packing.failed': '无法保存关联。',
};
export default tourPacking;
+3
View File
@@ -34,6 +34,7 @@ export * from './atlas/atlas.schema';
export * from './vacay/vacay.schema';
export * from './packing/packing.schema';
export * from './packing/packed-count';
export * from './packing/packing-placeholder';
export * from './todo/todo.schema';
export * from './budget/budget.schema';
export * from './budget/receipt-scan.schema';
@@ -64,6 +65,8 @@ 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-packing-link.schema';
export * from './tours/tour-packing-link';
export * from './tours/tour-poi-search.schema';
export * from './tours/tour-trail-grade.schema';
export * from './tours/tour-walk.schema';
@@ -0,0 +1,6 @@
/**
* The name the first item of a new, still empty packing category carries.
* The server refuses an item without a name, so an empty category lives on an
* item of this name; no list shows it as an item or counts it.
*/
export const PACKING_PLACEHOLDER_NAME = '...';

Some files were not shown because too many files have changed in this diff Show More