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
}