From 7fffd4300f1d1bac8a5d69a52c54709393573d62 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 17 Oct 2026 00:29:26 +0000 Subject: [PATCH] feat(tours): the paddle checklist through the offline core on the client PAD-06 and PAD-09 on the client's data side. A tour's cached row keeps its checklist beside its provenance, written through one cache helper by the repository and the queue replay, which also keeps provenance an answer lacks. The checklist is read for the bench the list shows and served from the cache while its revision matches. Decisions made offline, or behind a queued write of the same tour, change the cached checklist and bench at once under a local revision and replay with their idempotency key; a route written offline marks the checklist stale until the server judged it. The obstacle store learns a camera focus: a click in the rail moves the camera onto an obstacle and the other parts of its lock, the newest of such a focus and a picked search result wins, and a selected lock lights up whole on both maps. The checklist hook and its model hold the logic the cards will show. --- client/src/api/tourObstacleReviews.ts | 14 + .../Tours/paddle/PaddleLayerLeaflet.test.tsx | 2 +- .../Tours/paddle/PaddleLayerLeaflet.tsx | 16 +- .../Tours/paddle/PaddleRouteStrip.tsx | 4 +- .../Tours/paddle/paddleCheckModel.ts | 115 ++++++++ .../components/Tours/paddle/paddleMapLayer.ts | 21 +- .../Tours/paddle/usePaddleChecklist.ts | 257 ++++++++++++++++++ .../Tours/paddle/usePaddleLayerGL.test.ts | 2 +- .../Tours/paddle/usePaddleLayerGL.ts | 14 +- client/src/db/cacheTour.test.ts | 68 +++++ client/src/db/cacheTour.ts | 25 ++ client/src/db/offlineDb.ts | 5 +- .../tripPlanner/useMapLocationSearch.test.ts | 42 ++- .../pages/tripPlanner/useMapLocationSearch.ts | 48 ++-- .../pages/tripPlanner/useTourStageLayers.ts | 14 +- client/src/repo/obstacleCheckLocal.test.ts | 105 +++++++ client/src/repo/obstacleCheckLocal.ts | 63 +++++ client/src/repo/tourRepo.obstacles.test.ts | 206 ++++++++++++++ client/src/repo/tourRepo.ts | 118 ++++++-- client/src/store/paddleObstacleStore.ts | 58 +++- client/src/sync/mutationQueue.ts | 3 +- client/tests/helpers/paddleCheck.ts | 99 +++++++ .../mutationQueue.obstacleReviews.test.ts | 147 ++++++++++ 23 files changed, 1389 insertions(+), 57 deletions(-) create mode 100644 client/src/api/tourObstacleReviews.ts create mode 100644 client/src/components/Tours/paddle/paddleCheckModel.ts create mode 100644 client/src/components/Tours/paddle/usePaddleChecklist.ts create mode 100644 client/src/db/cacheTour.test.ts create mode 100644 client/src/db/cacheTour.ts create mode 100644 client/src/repo/obstacleCheckLocal.test.ts create mode 100644 client/src/repo/obstacleCheckLocal.ts create mode 100644 client/src/repo/tourRepo.obstacles.test.ts create mode 100644 client/tests/helpers/paddleCheck.ts create mode 100644 client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts diff --git a/client/src/api/tourObstacleReviews.ts b/client/src/api/tourObstacleReviews.ts new file mode 100644 index 000000000..6abe5c1d7 --- /dev/null +++ b/client/src/api/tourObstacleReviews.ts @@ -0,0 +1,14 @@ +import { tourDetailResponseSchema, type TourDetailResponse, type TourObstacleReviewsRequest } from '@trek/shared'; +import apiClient from './client'; + +/** PAD-06: what the paddlers decided about the obstacles of a paddle tour; answers the detail with its checklist. */ +export const tourObstacleReviewsApi = { + save: ( + tripId: number | string, + placeId: number | string, + body: TourObstacleReviewsRequest + ): Promise => + apiClient + .put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body) + .then((r) => tourDetailResponseSchema.parse(r.data)), +}; diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx index 26c6d6b45..08e051a3b 100644 --- a/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.test.tsx @@ -163,7 +163,7 @@ const layerOf = ( items: PaddleMapItem[], selectedRef: string | null = null, onSelect: (ref: string) => void = vi.fn() -): PaddleMapLayer => ({ items, selectedRef, onSelect }); +): PaddleMapLayer => ({ items, selectedRef, selectedKey: null, onSelect }); const lines = () => screen.queryAllByTestId('polyline'); const pins = () => screen.queryAllByTestId('pin'); diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx index 5d86fff1c..9179c89c2 100644 --- a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'; import { useTranslation } from '../../../i18n'; import { + isPaddleItemSelected, markerSize, paddleItemsAtZoom, paddleMarkerHtml, @@ -46,12 +47,19 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) { const [zoom, setZoom] = useState(() => map.getZoom()); useMapEvents({ zoomend: () => setZoom(map.getZoom()) }); const selectedRef = layer?.selectedRef ?? null; + const selectedKey = layer?.selectedKey ?? null; const all = layer?.items; const items = useMemo(() => (all ? paddleItemsAtZoom(all, zoom, selectedRef) : undefined), [all, zoom, selectedRef]); // Rebuilt only when the items or the selection change, not on every render of the map. const icons = useMemo( - () => new Map((items ?? []).map((item) => [item.feature.ref, iconOf(item, item.feature.ref === selectedRef)])), - [items, selectedRef] + () => + new Map( + (items ?? []).map((item) => [ + item.feature.ref, + iconOf(item, isPaddleItemSelected(item, selectedRef, selectedKey)), + ]) + ), + [items, selectedRef, selectedKey] ); if (!layer || !items?.length) return null; const select = (ref: string) => ({ @@ -98,7 +106,9 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) { key={`${item.feature.ref}-pin`} position={item.marker} icon={icons.get(item.feature.ref) as L.DivIcon} - zIndexOffset={item.feature.ref === selectedRef ? 900 : item.relation === 'on_route' ? 600 : 300} + zIndexOffset={ + isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300 + } eventHandlers={select(item.feature.ref)} > diff --git a/client/src/components/Tours/paddle/PaddleRouteStrip.tsx b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx index 1ec425354..2b3dc857c 100644 --- a/client/src/components/Tours/paddle/PaddleRouteStrip.tsx +++ b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx @@ -2,7 +2,7 @@ import type { PaddleLayout } from '@trek/shared'; import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useTranslation } from '../../../i18n'; -import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore'; +import { focusPaddleObstacle, selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDistance } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; @@ -73,7 +73,7 @@ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLa data-testid="paddle-route-strip-obstacle" aria-label={obstacle.name ? `${kind}: ${obstacle.name}` : kind} aria-pressed={picked} - onClick={() => selectPaddleObstacle(picked ? null : obstacle.ref)} + onClick={() => (picked ? selectPaddleObstacle(null) : focusPaddleObstacle(obstacle.ref))} className={`absolute top-1/2 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card shadow-sm rtl:translate-x-1/2 ${picked ? 'z-30 scale-125' : 'z-20'} ${obstacle.relation === 'near' && !picked ? 'opacity-60' : ''}`} style={{ insetInlineStart: `${(obstacle.atM / total) * 100}%`, diff --git a/client/src/components/Tours/paddle/paddleCheckModel.ts b/client/src/components/Tours/paddle/paddleCheckModel.ts new file mode 100644 index 000000000..7aa3cdefc --- /dev/null +++ b/client/src/components/Tours/paddle/paddleCheckModel.ts @@ -0,0 +1,115 @@ +import { + haversineMetres, + type PaddleCheck, + type PaddleCheckData, + type TourObstacleCheck, + type TourObstacleEntry, + type TourOwnEntry, +} from '@trek/shared'; +import { Ban, CircleDashed, ListChecks, type LucideIcon } from 'lucide-react'; + +type Translate = (key: string, params?: Record) => string; + +/** + * PAD-06 and PAD-09 on the client, without React: what the check bench of a + * paddle tour says in one pill, how its dates read, where the camera goes for + * an entry, and the order the checklist lists its rows in. + */ + +/** The word for data that does not cover the whole route; null when it does. */ +export function checkDataWord(data: PaddleCheckData, t: Translate): string | null { + return data === 'covered' ? null : t(`tours.paddle.check.data.${data}`); +} + +/** + * The bench in one pill: the first rule that applies gives its value. Missing + * data says so and never shows a count, open entries come before a partial + * coverage, and a route without known obstacles says that, never "clear". + */ +export function paddleCheckFact(check: PaddleCheck, t: Translate): { icon: LucideIcon; value: string; label: string } { + const pending = check.open + check.review; + const word = checkDataWord(check.data, t); + let value: string; + if (check.data === 'unavailable' || check.data === 'none') value = word ?? ''; + else if (pending > 0) value = t('tours.paddle.check.open', { count: pending }); + else if (word) value = word; + else if (check.entries === 0) value = t('tours.paddle.check.noneKnown'); + else value = t('tours.paddle.check.decided', { count: check.decided }); + const noData = check.data === 'unavailable' || check.data === 'none'; + const label = [ + word ? `${t('tours.paddle.check.dataLabel')}: ${word}` : null, + check.open > 0 ? t('tours.paddle.check.open', { count: check.open }) : null, + check.review > 0 ? t('tours.paddle.check.review', { count: check.review }) : null, + check.decided > 0 ? t('tours.paddle.check.decided', { count: check.decided }) : null, + check.bans > 0 ? t('tours.paddle.check.bans', { count: check.bans }) : null, + check.bans > 0 ? t('tours.paddle.check.bansInfo') : null, + t('tours.paddle.check.notClearance'), + ] + .filter((part): part is string => part !== null) + .join('\n'); + return { icon: noData ? CircleDashed : check.bans > 0 ? Ban : ListChecks, value, label }; +} + +const MONTH = /^(\d{4})-(\d{2})$/; + +/** A date of the checklist in the user's language: a bare `YYYY-MM` (an OSM check date) reads as month and year. */ +export function formatCheckDate(value: string, language: string): string { + const month = MONTH.exec(value); + try { + if (month) { + const date = new Date(Date.UTC(Number(month[1]), Number(month[2]) - 1, 1)); + return new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + // A bare day is a calendar day, not an instant in the user's zone. + const timeZone = /^\d{4}-\d{2}-\d{2}$/.test(value) ? 'UTC' : undefined; + return new Intl.DateTimeFormat(language, { dateStyle: 'medium', timeZone }).format(date); + } catch { + return value; + } +} + +/** The widest an area may be for the camera to frame it whole; beyond, it goes to the entry's point. */ +const FRAME_MAX_M = 2000; + +/** Where the camera goes for an entry or an own portage: its whole extent, or its point when that is too wide. */ +export function focusPointsOf(item: Pick): [number, number][] { + const [south, north] = item.bounds; + const at: [number, number] = [Number(item.at[0]), Number(item.at[1])]; + const corners: [number, number][] = [ + [Number(south[0]), Number(south[1])], + [Number(north[0]), Number(north[1])], + ]; + const diagonal = haversineMetres(corners[0][0], corners[0][1], corners[1][0], corners[1][1]); + return diagonal <= FRAME_MAX_M ? corners : [at]; +} + +export type ChecklistRow = { type: 'obstacle'; entry: TourObstacleEntry } | { type: 'own'; own: TourOwnEntry }; + +/** + * The rows of a checklist in route order, the paddler's own portages and + * locks among the obstacles at their metre, and apart from them the earlier + * decisions the route or the data no longer carries. + */ +export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[]; earlier: TourObstacleEntry[] } { + const metreOf = (row: ChecklistRow) => (row.type === 'obstacle' ? (row.entry.along_m ?? 0) : row.own.from_m); + const rows: ChecklistRow[] = [ + ...check.entries.filter((entry) => entry.relation !== null).map((entry) => ({ type: 'obstacle' as const, entry })), + ...check.own.map((own) => ({ type: 'own' as const, own })), + ].sort((a, b) => metreOf(a) - metreOf(b)); + return { rows, earlier: check.entries.filter((entry) => entry.relation === null) }; +} + +const PENDING = new Set(['open', 'todo', 'review']); + +/** The next entry along the route still asking for a look; from an earlier decision, the first one. */ +export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null { + const from = entry.along_m ?? -1; + return ( + check.entries + .filter((candidate) => candidate.relation !== null && PENDING.has(candidate.status)) + .filter((candidate) => candidate.key !== entry.key && (candidate.along_m ?? 0) > from) + .sort((a, b) => (a.along_m ?? 0) - (b.along_m ?? 0))[0] ?? null + ); +} diff --git a/client/src/components/Tours/paddle/paddleMapLayer.ts b/client/src/components/Tours/paddle/paddleMapLayer.ts index 59b534bf7..c7ce244c5 100644 --- a/client/src/components/Tours/paddle/paddleMapLayer.ts +++ b/client/src/components/Tours/paddle/paddleMapLayer.ts @@ -1,4 +1,4 @@ -import type { PaddleFeature, RelatedObstacle } from '@trek/shared'; +import { paddleObstacleKeyOf, type PaddleFeature, type RelatedObstacle } from '@trek/shared'; import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg, type PaddleGlyphKind } from './paddleGlyphPaths'; import { paddleKindPaint, type PaddlePaint } from './paddlePaint'; @@ -30,9 +30,28 @@ export interface PaddleMapItem { export interface PaddleMapLayer { items: PaddleMapItem[]; selectedRef: string | null; + /** The checklist key of the selected object (PAD-06): every part of a selected lock lights up with it. */ + selectedKey: string | null; onSelect: (ref: string) => void; } +/** The checklist key of the selected object among the features, or null when none is selected. */ +export function selectedPaddleKey(features: readonly PaddleFeature[], selectedRef: string | null): string | null { + const feature = selectedRef === null ? undefined : features.find((candidate) => candidate.ref === selectedRef); + return feature ? paddleObstacleKeyOf(feature) : null; +} + +/** Whether an item shows as selected: the object itself, or another part of the same lock. */ +export function isPaddleItemSelected( + item: PaddleMapItem, + selectedRef: string | null, + selectedKey: string | null +): boolean { + return ( + item.feature.ref === selectedRef || (selectedKey !== null && paddleObstacleKeyOf(item.feature) === selectedKey) + ); +} + function lineOf(feature: PaddleFeature): [number, number][] | null { const { geometry } = feature; if (geometry.type === 'Point') return null; diff --git a/client/src/components/Tours/paddle/usePaddleChecklist.ts b/client/src/components/Tours/paddle/usePaddleChecklist.ts new file mode 100644 index 000000000..40e5dcf0e --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleChecklist.ts @@ -0,0 +1,257 @@ +import { + haversineMetres, + paddleCheckSummaryOf, + type ObstacleDecision, + type ObstacleReviewOp, + type PaddleCheck, + type TourListItem, + type TourObstacleCheck, + type TourObstacleEntry, + type TourOwnEntry, + type TripMember, +} from '@trek/shared'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNetworkMode } from '../../../hooks/useNetworkMode'; +import { useTranslation } from '../../../i18n'; +import { applyObstacleOps } from '../../../repo/obstacleCheckLocal'; +import { tourRepo } from '../../../repo/tourRepo'; +import { useAuthStore } from '../../../store/authStore'; +import { + focusPaddleArea, + focusPaddleObstacle, + selectPaddleObstacle, + usePaddleObstacleStore, +} from '../../../store/paddleObstacleStore'; +import { useToast } from '../../shared/Toast'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { focusPointsOf, nextPendingEntry } from './paddleCheckModel'; +import type { TrackPaddlePlan } from './useTrackPaddlePlan'; + +export type PaddleChecklistStatus = 'off' | 'new' | 'loading' | 'ready' | 'offline-empty' | 'error'; + +interface PaddleChecklistOptions { + tripId: number; + planner: TourPlannerController; + tours: readonly TourListItem[]; + tripMembers: readonly TripMember[]; + canEdit: boolean; + /** The tours workspace is open on a desktop with the Tours addon on. */ + active: boolean; + trackPaddlePlan?: Pick; + /** The tour as it stands after a save, for the lists and the map to take it in. */ + onSaved: (tour: TourListItem) => void; +} + +interface Loaded { + key: string; + check: TourObstacleCheck | null; + stale: boolean; + failed: boolean; +} + +/** The paddle tour the planner has open, saved or shown as recorded, or null. */ +function openPaddleTour(mode: TourPlannerController['mode'], tours: readonly TourListItem[]): TourListItem | null { + if (mode.type !== 'edit-saved' && mode.type !== 'view-gpx') return null; + const tour = tours.find((item) => item.place_id === mode.placeId) ?? (mode.type === 'view-gpx' ? mode.tour : null); + return tour?.tour_type === 'kayak' ? tour : null; +} + +/** Reads the checklist for the bench the list shows, again whenever its revision or the connection changes. */ +function useChecklistRead(tripId: number, tour: TourListItem | null, active: boolean) { + const { offline } = useNetworkMode(); + const placeId = tour?.place_id ?? null; + const revision = tour?.paddle_check?.revision ?? null; + const key = `${placeId}:${revision}:${offline}`; + const [loaded, setLoaded] = useState(null); + useEffect(() => { + if (!active || placeId === null) return; + const controller = new AbortController(); + tourRepo + .obstacleCheck(tripId, placeId, revision, controller.signal) + .then(({ check, stale }) => { + if (!controller.signal.aborted) setLoaded({ key, check, stale, failed: false }); + }) + .catch(() => { + if (!controller.signal.aborted) setLoaded({ key, check: null, stale: false, failed: true }); + }); + return () => controller.abort(); + }, [active, key, placeId, revision, tripId]); + const current = loaded?.key === key ? loaded : null; + return { current, offline, key, setLoaded }; +} + +/** Where a portage for an obstacle starts on a planned tour: the control point whose leg passes it. */ +function waypointBefore(points: TourPlannerController['waypoints'], alongM: number): string | null { + let distance = 0; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + distance += haversineMetres(a.lat, a.lng, b.lat, b.lng); + if (distance >= alongM) return a.id; + } + return points[points.length - 2]?.id ?? null; +} + +/** + * PAD-06 and PAD-09 in the tours workspace: the checklist of the open paddle + * tour, read through the offline core for the bench the list shows, with the + * paddlers' decisions saved optimistically and rolled back on a failure. + * Deciding waits while the route has unsaved changes, since a decision binds + * to the route the server judged. + */ +export function usePaddleChecklist(options: PaddleChecklistOptions) { + const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options; + const { mode, waypoints, setSelectedWaypointId } = planner; + const tour = useMemo(() => openPaddleTour(mode, tours), [mode, tours]); + const read = useChecklistRead(tripId, tour, active); + const writes = useChecklistWrites(options, tour, read); + const selectedRef = usePaddleObstacleStore((state) => state.selectedRef); + const [dialogKey, setDialogKey] = useState(null); + + const check = writes.optimistic ?? read.current?.check ?? null; + const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty; + let status: PaddleChecklistStatus = 'off'; + if (active && mode.type === 'new-draft' && planner.isPaddle) status = 'new'; + else if (active && tour) { + if (!read.current) status = 'loading'; + else if (read.current.check) status = 'ready'; + else status = read.offline ? 'offline-empty' : 'error'; + } + const placeId = tour?.place_id ?? -1; + const summary: PaddleCheck | null = + writes.optimistic && tour?.paddle_check + ? { + ...paddleCheckSummaryOf(writes.optimistic.entries, writes.optimistic.own, tour.paddle_check), + revision: tour.paddle_check.revision, + } + : (tour?.paddle_check ?? null); + + const entryForRef = useCallback( + (ref: string) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null, + [check] + ); + const select = useCallback((entry: TourObstacleEntry) => { + const ref = entry.refs[0]; + if (ref && focusPaddleObstacle(ref)) { + setDialogKey(null); + return; + } + selectPaddleObstacle(null); + focusPaddleArea(focusPointsOf(entry)); + setDialogKey(entry.key); + }, []); + const selectOwn = useCallback( + (own: TourOwnEntry) => { + focusPaddleArea(focusPointsOf(own)); + const waypoint = own.index === null ? undefined : waypoints[own.index]; + if (mode.type === 'edit-saved' && waypoint) setSelectedWaypointId(waypoint.id); + }, + [mode.type, setSelectedWaypointId, waypoints] + ); + // A track takes the portage on its own pick; a planned tour selects the control point to carry from. + const planPortage = useCallback( + (entry: TourObstacleEntry) => { + selectPaddleObstacle(null); + setDialogKey(null); + focusPaddleArea(focusPointsOf(entry)); + if (mode.type === 'view-gpx') { + if (trackPaddlePlan?.pick !== 'portage') trackPaddlePlan?.startPick('portage'); + return; + } + const waypoint = waypointBefore(waypoints, entry.along_m ?? 0); + if (waypoint) setSelectedWaypointId(waypoint); + }, + [mode.type, setSelectedWaypointId, trackPaddlePlan, waypoints] + ); + const nextOf = useCallback((entry: TourObstacleEntry) => (check ? nextPendingEntry(check, entry) : null), [check]); + + return { + status, + tour, + check, + summary, + stale: read.current?.stale ?? false, + dirty, + canDecide: canEdit && status === 'ready' && !dirty && placeId > 0 && writes.savingKey === null, + savingKey: writes.savingKey, + selectedKey: (selectedRef ? entryForRef(selectedRef)?.key : null) ?? dialogKey, + dialogKey, + entryForRef, + select, + selectOwn, + closeDialog: () => setDialogKey(null), + ...writes.actions, + nextOf, + next: (entry: TourObstacleEntry) => { + const following = nextOf(entry); + if (following) select(following); + }, + planPortage, + memberName: (id: number | null) => options.tripMembers.find((member) => member.id === id)?.username ?? null, + }; +} + +export type PaddleChecklist = ReturnType; + +/** The decisions saved through the offline core: shown at once, the server's answer taken in, a failure undone. */ +function useChecklistWrites( + options: PaddleChecklistOptions, + tour: TourListItem | null, + read: ReturnType +) { + const { tripId, onSaved } = options; + const { t } = useTranslation(); + const toast = useToast(); + const userId = useAuthStore((state) => state.user?.id ?? null); + const [pending, setPending] = useState<{ key: string; check: TourObstacleCheck } | null>(null); + const base = read.current?.check ?? null; + const placeId = tour?.place_id ?? null; + const { setLoaded, offline } = read; + + const save = useCallback( + async (key: string, op: ObstacleReviewOp) => { + if (!base || placeId === null) return; + setPending({ + key, + check: applyObstacleOps(base, [op], { now: new Date().toISOString(), userId, revision: 'pending' }), + }); + try { + const result = await tourRepo.saveObstacleReviews(tripId, placeId, { ops: [op] }, userId); + const revision = result.tour.paddle_check?.revision ?? null; + setLoaded({ + key: `${placeId}:${revision}:${offline}`, + check: result.obstacle_check ?? base, + stale: false, + failed: false, + }); + onSaved(result.tour); + } catch { + toast.error(t('tours.paddle.marks.failed')); + } finally { + setPending(null); + } + }, + [base, offline, onSaved, placeId, setLoaded, t, toast, tripId, userId] + ); + + const actions = useMemo( + () => ({ + decide: (entry: TourObstacleEntry, decision: ObstacleDecision) => + save(entry.key, { + key: entry.key, + op: 'decide', + decision, + note: entry.decision?.note ?? null, + basis: entry.basis, + }), + setNote: (entry: TourObstacleEntry, note: string | null) => + save(entry.key, { key: entry.key, op: 'note', decision: null, note: note?.trim() || null, basis: null }), + clear: (entry: TourObstacleEntry) => + save(entry.key, { key: entry.key, op: 'clear', decision: null, note: null, basis: null }), + keep: (entry: TourObstacleEntry) => + save(entry.key, { key: entry.key, op: 'keep', decision: null, note: null, basis: null }), + }), + [save] + ); + return { optimistic: pending?.check ?? null, savingKey: pending?.key ?? null, actions }; +} diff --git a/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts index 0bdff4b16..dae240ce5 100644 --- a/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts +++ b/client/src/components/Tours/paddle/usePaddleLayerGL.test.ts @@ -87,7 +87,7 @@ function itemOf(ref: string): PaddleMapItem { } function layerOf(items: PaddleMapItem[], selectedRef: string | null = null, onSelect = vi.fn()): PaddleMapLayer { - return { items, selectedRef, onSelect }; + return { items, selectedRef, selectedKey: null, onSelect }; } const looksOf = (item: PaddleMapItem) => [paddleMarkerImageId(item, false), paddleMarkerImageId(item, true)]; diff --git a/client/src/components/Tours/paddle/usePaddleLayerGL.ts b/client/src/components/Tours/paddle/usePaddleLayerGL.ts index 25f0052f6..7a22b13b8 100644 --- a/client/src/components/Tours/paddle/usePaddleLayerGL.ts +++ b/client/src/components/Tours/paddle/usePaddleLayerGL.ts @@ -2,6 +2,7 @@ import type { ExpressionSpecification, Map, MapMouseEvent } from 'mapbox-gl'; import { useEffect, useRef } from 'react'; import { isAreaOnly, + isPaddleItemSelected, markerSize, PADDLE_AREA_MIN_ZOOM, paddleMarkerImageId, @@ -29,10 +30,14 @@ const HIT_LAYERS = [PINS, PINS_AREA, LINE, LINE_NEAR, LINE_AREA]; const WIDTH = { on_route: 4, near: 3, area: 2 } as const; const OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const; -function collection(items: readonly PaddleMapItem[], selectedRef: string | null): GeoJSON.FeatureCollection { +function collection( + items: readonly PaddleMapItem[], + selectedRef: string | null, + selectedKey: string | null +): GeoJSON.FeatureCollection { const features: GeoJSON.Feature[] = []; for (const item of items) { - const selected = item.feature.ref === selectedRef; + const selected = isPaddleItemSelected(item, selectedRef, selectedKey); const area = isAreaOnly(item, selectedRef); if (item.line) { features.push({ @@ -195,11 +200,12 @@ export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer }, [layer?.onSelect]); const items = layer?.items; const selectedRef = layer?.selectedRef ?? null; + const selectedKey = layer?.selectedKey ?? null; useEffect(() => { if (!map || !items?.length) return; let gone = false; - const data = collection(items, selectedRef); + const data = collection(items, selectedRef, selectedKey); let applied: GeoJSON.FeatureCollection | null = null; const apply = () => { try { @@ -220,7 +226,7 @@ export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer gone = true; map.off('styledata', apply); }; - }, [items, map, selectedRef]); + }, [items, map, selectedRef, selectedKey]); useEffect(() => { if (!map) return; diff --git a/client/src/db/cacheTour.test.ts b/client/src/db/cacheTour.test.ts new file mode 100644 index 000000000..e572c15fe --- /dev/null +++ b/client/src/db/cacheTour.test.ts @@ -0,0 +1,68 @@ +// A tour answer written to the cache (PAD-06): its control points, and where it +// came from and its checklist kept from the cached row when the answer lacks them. +import type { TourListItem } from '@trek/shared'; +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { obstacleCheck } from '../../tests/helpers/paddleCheck'; +import { cacheTourAnswer } from './cacheTour'; +import { clearAll, offlineDb } from './offlineDb'; + +function tour(overrides: Partial = {}): TourListItem { + return { + place_id: 40, + name: 'Regen', + tour_type: 'kayak', + distance: 8, + elevation_gain: null, + elevation_loss: null, + duration: 120, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: false, + caution: false, + has_waypoints: true, + ...overrides, + }; +} + +const waypoints = [ + { lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 }, + { lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 }, +]; +const provenance = { + origin: 'trek' as const, + createdAt: null, + source: null, + derivedFrom: null, + plannedOver: false, + editedLocally: null, +}; + +beforeEach(async () => { + await clearAll(); +}); + +describe('cacheTourAnswer', () => { + it('CT-001: writes the tour with its trip, control points, provenance and checklist', async () => { + const check = obstacleCheck(); + const row = await cacheTourAnswer(3, { tour: tour(), waypoints, provenance, obstacle_check: check }); + expect(row).toMatchObject({ place_id: 40, trip_id: 3, waypoints, provenance, obstacle_check: check }); + expect(await offlineDb.tours.get(40)).toEqual(row); + }); + + it('CT-002: an answer without provenance or checklist keeps the cached ones', async () => { + const check = obstacleCheck(); + await cacheTourAnswer(3, { tour: tour(), waypoints, provenance, obstacle_check: check }); + const row = await cacheTourAnswer(3, { tour: tour({ name: 'Regen again' }), waypoints }); + expect(row).toMatchObject({ name: 'Regen again', provenance, obstacle_check: check }); + }); + + it('CT-003: a tour that is no paddle tour any more drops its checklist', async () => { + await cacheTourAnswer(3, { tour: tour(), waypoints, obstacle_check: obstacleCheck() }); + const row = await cacheTourAnswer(3, { tour: tour({ tour_type: 'hike' }), waypoints }); + expect(row).not.toHaveProperty('obstacle_check'); + }); +}); diff --git a/client/src/db/cacheTour.ts b/client/src/db/cacheTour.ts new file mode 100644 index 000000000..749702a5f --- /dev/null +++ b/client/src/db/cacheTour.ts @@ -0,0 +1,25 @@ +import type { TourCreateResponse } from '@trek/shared'; +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 and a paddle tour's checklist 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 { + const previous = await offlineDb.tours.get(answer.tour.place_id); + const provenance = answer.provenance ?? previous?.provenance; + // 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 row: CachedTour = { + ...answer.tour, + trip_id: tripId, + waypoints: answer.waypoints, + ...(provenance ? { provenance } : {}), + ...(check ? { obstacle_check: check } : {}), + }; + await offlineDb.tours.put(row); + return row; +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 53ae0809a..47cd8f957 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -1,4 +1,4 @@ -import type { Ferrata, RoadtripPreferences, TourListItem, TourProvenance, TourWaypoint } from '@trek/shared'; +import type { Ferrata, RoadtripPreferences, TourListItem, TourObstacleCheck, TourProvenance, 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'; @@ -17,6 +17,8 @@ export interface CachedTour extends TourListItem { waypoints?: TourWaypoint[]; /** Where the tour came from, as its detail last said; kept across list refreshes, since it does not change. */ provenance?: TourProvenance; + /** A paddle tour's obstacle checklist as its detail last said; its revision tells whether the list's bench still matches. */ + obstacle_check?: TourObstacleCheck; } /** @@ -471,6 +473,7 @@ export async function replaceTripTours(tripId: number, tours: TourListItem[]): P trip_id: tripId, ...(sameRoute && previous.waypoints ? { waypoints: previous.waypoints } : {}), ...(previous?.provenance ? { provenance: previous.provenance } : {}), + ...(previous?.obstacle_check ? { obstacle_check: previous.obstacle_check } : {}), }; })); }); diff --git a/client/src/pages/tripPlanner/useMapLocationSearch.test.ts b/client/src/pages/tripPlanner/useMapLocationSearch.test.ts index 93732d3c5..0118effef 100644 --- a/client/src/pages/tripPlanner/useMapLocationSearch.test.ts +++ b/client/src/pages/tripPlanner/useMapLocationSearch.test.ts @@ -1,7 +1,8 @@ -// FE-TP-LOCSEARCH-001 to FE-TP-LOCSEARCH-004 +// FE-TP-LOCSEARCH-001 to FE-TP-LOCSEARCH-006 // // The tour planner's map location search: a picked result only moves the camera, -// and the planner's own focus takes over again once it bumps its focus key. +// a focus the paddle checklist asks for does too and the newest wins, and the +// planner's own focus takes over again once it bumps its focus key. import { act, renderHook } from '@testing-library/react'; import { useMapLocationSearch } from './useMapLocationSearch'; @@ -53,4 +54,41 @@ describe('useMapLocationSearch', () => { expect(result.current.mapFocusPoints).toBe(PLANNER_POINTS); expect(result.current.mapFocusKey).toBe('tour:4'); }); + + it('FE-TP-LOCSEARCH-005: a focus the paddle checklist asks for frames its area, and the newest of it and a pick wins', () => { + const weir: [number, number][] = [ + [49.199, 12.004], + [49.201, 12.006], + ]; + const { result, rerender } = renderHook(({ focus }) => useMapLocationSearch(3, PLANNER_POINTS, focus), { + initialProps: { focus: null as { seq: number; points: [number, number][] } | null }, + }); + + rerender({ focus: { seq: 1, points: weir } }); + expect(result.current.mapFocusPoints).toEqual(weir); + expect(result.current.mapFocusKey).toBe('tour:3:paddle:1'); + + act(() => result.current.locationSearch.onChange(PICK)); + expect(result.current.mapFocusKey).toBe('tour:3:search:2'); + + rerender({ focus: { seq: 2, points: weir } }); + expect(result.current.mapFocusKey).toBe('tour:3:paddle:3'); + expect(result.current.mapFocusPoints).toEqual(weir); + }); + + it('FE-TP-LOCSEARCH-006: a focus already there when the planner opens, or one without points, moves nothing; a new planner key wins', () => { + const weir: [number, number][] = [[49.2, 12.005]]; + const { result, rerender } = renderHook(({ key, focus }) => useMapLocationSearch(key, PLANNER_POINTS, focus), { + initialProps: { key: 3, focus: { seq: 4, points: weir } as { seq: number; points: [number, number][] } }, + }); + expect(result.current.mapFocusKey).toBe('tour:3'); + + rerender({ key: 3, focus: { seq: 5, points: [] } }); + expect(result.current.mapFocusKey).toBe('tour:3'); + + rerender({ key: 3, focus: { seq: 6, points: weir } }); + expect(result.current.mapFocusKey).toBe('tour:3:paddle:1'); + rerender({ key: 4, focus: { seq: 6, points: weir } }); + expect(result.current.mapFocusKey).toBe('tour:4'); + }); }); diff --git a/client/src/pages/tripPlanner/useMapLocationSearch.ts b/client/src/pages/tripPlanner/useMapLocationSearch.ts index cc4d0d8c4..65fd93d2f 100644 --- a/client/src/pages/tripPlanner/useMapLocationSearch.ts +++ b/client/src/pages/tripPlanner/useMapLocationSearch.ts @@ -1,38 +1,54 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import type { LocationPoint } from '../../components/Planner/LocationSelect'; +import type { PaddleFocus } from '../../store/paddleObstacleStore'; -interface SearchCameraIntent { +interface CameraIntent { plannerFocusKey: number; sequence: number; - point: [number, number]; + /** A picked search result, or an obstacle or area the paddle checklist asked for. */ + kind: 'search' | 'paddle'; + points: [number, number][]; } /** * The TOUR-PLANNER location search: a picked result only moves the camera. - * The search focus wins until the planner bumps its own focus key. + * A focus the paddle checklist asks for (PAD-06) is an intent of the same + * kind, and the newest of them wins until the planner bumps its own focus key. */ -export function useMapLocationSearch(plannerFocusKey: number, focusPoints: [number, number][]) { +export function useMapLocationSearch( + plannerFocusKey: number, + focusPoints: [number, number][], + paddleFocus: PaddleFocus | null = null +) { const [locationSearchValue, setLocationSearchValue] = useState(null); - const [searchCameraIntent, setSearchCameraIntent] = useState(null); - const searchCameraSequenceRef = useRef(0); + const [intent, setIntent] = useState(null); + const [seenPaddleSeq, setSeenPaddleSeq] = useState(paddleFocus?.seq ?? null); + // A new focus from the checklist becomes the newest intent, adjusted while rendering as React advises for a changed prop. + if ((paddleFocus?.seq ?? null) !== seenPaddleSeq) { + setSeenPaddleSeq(paddleFocus?.seq ?? null); + if (paddleFocus && paddleFocus.points.length > 0) { + const points = paddleFocus.points; + setIntent((current) => ({ plannerFocusKey, sequence: (current?.sequence ?? 0) + 1, kind: 'paddle', points })); + } + } const onLocationSearchChange = useCallback( (location: LocationPoint | null) => { setLocationSearchValue(location); if (!location) return; - searchCameraSequenceRef.current += 1; - setSearchCameraIntent({ + setIntent((current) => ({ plannerFocusKey, - sequence: searchCameraSequenceRef.current, - point: [location.lat, location.lng], - }); + sequence: (current?.sequence ?? 0) + 1, + kind: 'search', + points: [[location.lat, location.lng]], + })); }, [plannerFocusKey] ); - const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === plannerFocusKey; - const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints; - const mapFocusKey = searchFocusIsCurrent - ? `tour:${plannerFocusKey}:search:${searchCameraIntent.sequence}` + const current = intent?.plannerFocusKey === plannerFocusKey ? intent : null; + const mapFocusPoints = current ? current.points : focusPoints; + const mapFocusKey = current + ? `tour:${plannerFocusKey}:${current.kind}:${current.sequence}` : `tour:${plannerFocusKey}`; return { diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts index 631b58097..5759af5d6 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer'; -import { paddleMapItems } from '../../components/Tours/paddle/paddleMapLayer'; +import { paddleMapItems, selectedPaddleKey } from '../../components/Tours/paddle/paddleMapLayer'; import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint'; import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers'; @@ -42,8 +42,16 @@ export function useTourStageLayers( const paddlePaint = usePaddlePaint(); const paddleItems = useMemo(() => paddleMapItems(features, related, paddlePaint), [features, related, paddlePaint]); const paddle = useMemo( - () => (paddleItems.length ? { items: paddleItems, selectedRef, onSelect: selectPaddleObstacle } : undefined), - [paddleItems, selectedRef] + () => + paddleItems.length + ? { + items: paddleItems, + selectedRef, + selectedKey: selectedPaddleKey(features, selectedRef), + onSelect: selectPaddleObstacle, + } + : undefined, + [paddleItems, features, selectedRef] ); const tourLayers = useMemo( () => ({ bases: layerSet.bases, overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [], paddle }), diff --git a/client/src/repo/obstacleCheckLocal.test.ts b/client/src/repo/obstacleCheckLocal.test.ts new file mode 100644 index 000000000..3b721b63a --- /dev/null +++ b/client/src/repo/obstacleCheckLocal.test.ts @@ -0,0 +1,105 @@ +// The checklist worked out here before the server answers (PAD-06): a decision +// read against the entry's own basis, a note, removing and keeping a decision. +import { describe, expect, it } from 'vitest'; + +import { earlierEntry, obstacleCheck, obstacleEntry, ROUTE_BASIS, SOURCE_BASIS } from '../../tests/helpers/paddleCheck'; +import { applyObstacleOps } from './obstacleCheckLocal'; + +const ctx = { now: '2026-10-17T09:00:00.000Z', userId: 4, revision: 'local:abc' }; +const basis = { route: ROUTE_BASIS, source: SOURCE_BASIS }; + +describe('applyObstacleOps', () => { + it('OCL-001: a decision on the basis shown is decided, a portage without a planned carry is to do', () => { + const check = obstacleCheck(); + const passed = applyObstacleOps( + check, + [{ key: 'node/1', op: 'decide', decision: 'pass', note: 'Links', basis }], + ctx + ); + expect(passed.revision).toBe('local:abc'); + expect(passed.entries[0]).toMatchObject({ + status: 'decided', + changed: [], + decision: { decision: 'pass', note: 'Links', decided_at: ctx.now, decided_by: 4 }, + }); + const carried = applyObstacleOps( + check, + [{ key: 'node/1', op: 'decide', decision: 'portage', note: null, basis }], + ctx + ); + expect(carried.entries[0]?.status).toBe('todo'); + const planned = applyObstacleOps( + obstacleCheck({ entries: [obstacleEntry({ portage: 'planned' })] }), + [{ key: 'node/1', op: 'decide', decision: 'portage', note: null, basis }], + ctx + ); + expect(planned.entries[0]?.status).toBe('decided'); + }); + + it('OCL-002: a decision on another basis than the entry carries reads as one to check again', () => { + const stale = { route: ROUTE_BASIS.replace('p1:o:', 'p1:n:'), source: SOURCE_BASIS }; + const next = applyObstacleOps( + obstacleCheck(), + [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis: stale }], + ctx + ); + expect(next.entries[0]).toMatchObject({ status: 'review', changed: ['route'] }); + }); + + it('OCL-003: the same decision again keeps when it was made', () => { + const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-01T08:00:00.000Z', decided_by: 2 }; + const check = obstacleCheck({ entries: [obstacleEntry({ status: 'decided', decision })] }); + const next = applyObstacleOps(check, [{ key: 'node/1', op: 'decide', decision: 'pass', note: null, basis }], ctx); + expect(next.entries[0]?.decision).toEqual(decision); + }); + + it('OCL-004: a note changes only the note, and nothing without a decision', () => { + const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-01T08:00:00.000Z', decided_by: 2 }; + const check = obstacleCheck({ + entries: [obstacleEntry({ status: 'decided', decision }), obstacleEntry({ key: 'node/2' })], + }); + const next = applyObstacleOps( + check, + [ + { key: 'node/1', op: 'note', decision: null, note: 'Rampe rechts', basis: null }, + { key: 'node/2', op: 'note', decision: null, note: 'nothing to note', basis: null }, + ], + ctx + ); + expect(next.entries[0]?.decision).toEqual({ ...decision, note: 'Rampe rechts' }); + expect(next.entries[1]?.decision).toBeNull(); + }); + + it('OCL-005: removing a decision reopens a current entry and drops an earlier one', () => { + const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-01T08:00:00.000Z', decided_by: 2 }; + const check = obstacleCheck({ entries: [obstacleEntry({ status: 'decided', decision }), earlierEntry()] }); + const next = applyObstacleOps( + check, + [ + { key: 'node/1', op: 'clear', decision: null, note: null, basis: null }, + { key: 'node/9', op: 'clear', decision: null, note: null, basis: null }, + ], + ctx + ); + expect(next.entries).toEqual([obstacleEntry({ status: 'open', changed: [], decision: null })]); + }); + + it('OCL-006: keeping turns a vanished object kept, anything else stays; a decision on an earlier entry is ignored', () => { + const check = obstacleCheck({ entries: [obstacleEntry(), earlierEntry()] }); + const next = applyObstacleOps( + check, + [ + { key: 'node/1', op: 'keep', decision: null, note: null, basis: null }, + { key: 'node/9', op: 'keep', decision: null, note: null, basis: null }, + ], + ctx + ); + expect(next.entries.map((entry) => entry.status)).toEqual(['open', 'kept']); + const decided = applyObstacleOps( + check, + [{ key: 'node/9', op: 'decide', decision: 'portage', note: null, basis }], + ctx + ); + expect(decided.entries[1]).toEqual(earlierEntry()); + }); +}); diff --git a/client/src/repo/obstacleCheckLocal.ts b/client/src/repo/obstacleCheckLocal.ts new file mode 100644 index 000000000..e9ee86d5c --- /dev/null +++ b/client/src/repo/obstacleCheckLocal.ts @@ -0,0 +1,63 @@ +import { obstacleStatusOf, type ObstacleReviewOp, type TourObstacleCheck, type TourObstacleEntry } from '@trek/shared'; + +interface LocalContext { + /** When the change was made, as an ISO instant. */ + now: string; + userId: number | null; + /** The revision the changed checklist carries until the server answers. */ + revision: string; +} + +/** A decision on a current entry, read against the entry's own basis as the server will. */ +function decided(entry: TourObstacleEntry, op: ObstacleReviewOp, ctx: LocalContext): TourObstacleEntry { + if (!entry.basis || !op.decision || !op.basis) return entry; + const note = op.note || null; + const record = entry.decision; + const same = + record?.decision === op.decision && + record.note === note && + entry.basis.route === op.basis.route && + entry.basis.source === op.basis.source; + const { status, changed } = obstacleStatusOf({ + current: { route: entry.basis.route, source: entry.basis.source, portage: entry.portage }, + review: { decision: op.decision, route: op.basis.route, source: op.basis.source }, + }); + return { + ...entry, + status, + changed, + decision: same && record ? record : { decision: op.decision, note, decided_at: ctx.now, decided_by: ctx.userId }, + }; +} + +function applied(entry: TourObstacleEntry, op: ObstacleReviewOp, ctx: LocalContext): TourObstacleEntry | null { + if (op.op === 'decide') return entry.relation === null ? entry : decided(entry, op, ctx); + if (op.op === 'note') + return entry.decision ? { ...entry, decision: { ...entry.decision, note: op.note || null } } : entry; + if (op.op === 'clear') { + // An earlier decision goes with its entry; a current entry is open again. + if (entry.relation === null) return null; + return { ...entry, status: 'open', changed: [], decision: null }; + } + return entry.status === 'gone' ? { ...entry, status: 'kept' } : entry; +} + +/** + * The checklist as it reads once the changes land, worked out here for an + * offline save or the moment before the server answers: the server stores + * the same decisions against the same basis and answers the same statuses. + */ +export function applyObstacleOps( + check: TourObstacleCheck, + ops: readonly ObstacleReviewOp[], + ctx: LocalContext +): TourObstacleCheck { + const byKey = new Map(ops.map((op) => [op.key, op])); + const entries = check.entries.flatMap((entry) => { + const op = byKey.get(entry.key); + if (!op) return [entry]; + const next = applied(entry, op, ctx); + return next ? [next] : []; + }); + return { ...check, revision: ctx.revision, entries }; +} diff --git a/client/src/repo/tourRepo.obstacles.test.ts b/client/src/repo/tourRepo.obstacles.test.ts new file mode 100644 index 000000000..88017ffcb --- /dev/null +++ b/client/src/repo/tourRepo.obstacles.test.ts @@ -0,0 +1,206 @@ +// FE-REPO-TOUR-030 to FE-REPO-TOUR-038 +// A paddle tour's checklist through the offline core (PAD-06, PAD-09): read +// from the cache while its revision matches the bench, decisions saved online +// and cached, saved offline under a local revision and queued, and a route +// written offline marking the checklist stale. +import type { TourListItem } 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 { obstacleCheck, obstacleEntry, paddleBench, ROUTE_BASIS, SOURCE_BASIS } from '../../tests/helpers/paddleCheck'; +import { clearAll, offlineDb, replaceTripTours } from '../db/offlineDb'; +import { mutationQueue } from '../sync/mutationQueue'; +import { tourRepo } from './tourRepo'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +function paddleTour(overrides: Partial = {}): TourListItem { + return { + place_id: 40, + name: 'Regen', + tour_type: 'kayak', + distance: 8, + elevation_gain: null, + elevation_loss: null, + duration: 120, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: false, + caution: false, + has_waypoints: true, + boat_type: 'kayak', + paddle_check: paddleBench(), + ...overrides, + }; +} + +const waypoints = [ + { lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 }, + { lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 }, +]; +const decide = { + ops: [ + { + key: 'node/1', + op: 'decide' as const, + decision: 'pass' as const, + note: null, + basis: { route: ROUTE_BASIS, source: SOURCE_BASIS }, + }, + ], +}; + +beforeEach(async () => { + await clearAll(); + setOnline(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setOnline(true); +}); + +describe('tourRepo.obstacleCheck', () => { + it('FE-REPO-TOUR-030: the cached checklist serves while its revision matches the bench, without a request', async () => { + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() }); + let asked = 0; + server.use( + http.get('/api/trips/3/tours/40', () => { + asked++; + return HttpResponse.json({}); + }) + ); + expect(await tourRepo.obstacleCheck(3, 40, 'rev-1')).toEqual({ check: obstacleCheck(), stale: false }); + expect(asked).toBe(0); + }); + + it('FE-REPO-TOUR-031: another revision reads the detail and caches its checklist', async () => { + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, obstacle_check: obstacleCheck() }); + const fresh = obstacleCheck({ revision: 'rev-2' }); + server.use( + http.get('/api/trips/3/tours/40', () => + HttpResponse.json({ + tour: paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) }), + waypoints, + obstacle_check: fresh, + }) + ) + ); + expect(await tourRepo.obstacleCheck(3, 40, 'rev-2')).toEqual({ check: fresh, stale: false }); + expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(fresh); + }); + + it('FE-REPO-TOUR-032: offline, an older cached checklist stands in and reads as stale', async () => { + setOnline(false); + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() }); + expect(await tourRepo.obstacleCheck(3, 40, 'local:abc')).toEqual({ check: obstacleCheck(), stale: true }); + }); +}); + +describe('tourRepo.saveObstacleReviews', () => { + it('FE-REPO-TOUR-033: online, the answer is cached with its checklist', async () => { + const decided = obstacleCheck({ revision: 'rev-2', entries: [obstacleEntry({ status: 'decided' })] }); + let body: unknown; + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ tour: paddleTour(), waypoints, obstacle_check: decided }); + }) + ); + const result = await tourRepo.saveObstacleReviews(3, 40, decide, 4); + expect(body).toEqual(decide); + expect(result.obstacle_check).toEqual(decided); + expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(decided); + }); + + it('FE-REPO-TOUR-034: offline, the cached checklist and bench take the decision under a local revision, and a PUT is queued', async () => { + setOnline(false); + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() }); + const send = vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined); + + const result = await tourRepo.saveObstacleReviews(3, 40, decide, 4); + + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(mutation).toMatchObject({ + method: 'PUT', + url: '/trips/3/tours/40/obstacle-reviews', + body: decide, + resource: 'tours', + entityId: 40, + }); + const revision = `local:${mutation?.id}`; + expect(result.obstacle_check?.revision).toBe(revision); + expect(result.obstacle_check?.entries[0]).toMatchObject({ status: 'decided', decision: { decided_by: 4 } }); + expect(result.tour.paddle_check).toMatchObject({ open: 0, decided: 1, revision, data: 'covered' }); + expect(result.tour).not.toHaveProperty('obstacle_check'); + expect((await offlineDb.tours.get(40))?.paddle_check?.revision).toBe(revision); + expect(send).toHaveBeenCalled(); + }); + + it('FE-REPO-TOUR-035: a tour not saved yet, or one without a cached checklist offline, refuses', async () => { + await expect(tourRepo.saveObstacleReviews(3, -2, decide, 4)).rejects.toThrow('Save the tour'); + setOnline(false); + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3 }); + await expect(tourRepo.saveObstacleReviews(3, 40, decide, 4)).rejects.toThrow('not available offline'); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); + + it('FE-REPO-TOUR-036: online, a decision waits behind an older queued write of the tour', async () => { + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() }); + await offlineDb.mutationQueue.put({ + id: 'parked-tour', + tripId: 3, + method: 'PUT', + url: '/trips/3/tours/40/paddle-plan', + body: {}, + createdAt: 1, + status: 'failed', + attempts: 8, + lastError: 'boom', + resource: 'tours', + entityId: 40, + }); + vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined); + let sent = 0; + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', () => { + sent++; + return HttpResponse.json({}); + }) + ); + await tourRepo.saveObstacleReviews(3, 40, decide, 4); + expect(sent).toBe(0); + expect((await offlineDb.mutationQueue.toArray()).map((m) => m.status).sort()).toEqual(['failed', 'pending']); + }); +}); + +describe('a route written offline and the list cache', () => { + it('FE-REPO-TOUR-037: an offline paddle plan marks the bench stale, so the checklist no longer matches it', async () => { + setOnline(false); + await offlineDb.tours.put({ ...paddleTour({ has_waypoints: false }), trip_id: 3, obstacle_check: obstacleCheck() }); + vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined); + const result = await tourRepo.updatePaddlePlan(3, 40, { + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: { portages: [], locks: [] }, + }); + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(result.tour.paddle_check?.revision).toBe(`local:${mutation?.id}`); + expect((await tourRepo.obstacleCheck(3, 40, result.tour.paddle_check?.revision ?? null)).stale).toBe(true); + }); + + it('FE-REPO-TOUR-038: a list refresh keeps the cached checklist, and the list items never carry it', async () => { + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, obstacle_check: obstacleCheck() }); + await replaceTripTours(3, [paddleTour({ name: 'Regen renamed' })]); + expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(obstacleCheck()); + setOnline(false); + const { tours } = await tourRepo.list(3); + expect(tours[0]).not.toHaveProperty('obstacle_check'); + }); +}); diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 3c7698072..bf486c3e9 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -1,20 +1,32 @@ -import { computeTourMetrics, estimatePaddleTime, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared' +import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared' import { toursApi } from '../api/client' import { tourGpxApi } from '../api/tourGpx' import { recordedTrackApi } from '../api/recordedTrack' import { tourPaddlePlanApi } from '../api/tourPaddlePlan' import { tourCopiesApi } from '../api/tourCopies' +import { tourObstacleReviewsApi } from '../api/tourObstacleReviews' +import { cacheTourAnswer } from '../db/cacheTour' import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb' import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' import { isEffectivelyOffline } from '../sync/networkMode' +import { applyObstacleOps } from './obstacleCheckLocal' 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, ...tour }: CachedTour): TourListItem { +function toListItem({ trip_id: _tripId, waypoints: _waypoints, provenance: _provenance, obstacle_check: _check, ...tour }: CachedTour): TourListItem { return tour } +/** + * A paddle tour's bench after a route write made here offline: the server has + * not judged the new route yet, so its revision no longer matches any + * checklist and the checklist reads as stale until the write replays. + */ +function staleCheck(tour: CachedTour, mutationId: string): CachedTour { + return tour.paddle_check ? { ...tour, paddle_check: { ...tour.paddle_check, revision: `local:${mutationId}` } } : tour +} + /** Newest first, as the server lists them: offline creates (negative ids) on top. */ function newestFirst(a: CachedTour, b: CachedTour): number { if ((a.place_id < 0) !== (b.place_id < 0)) return a.place_id < 0 ? -1 : 1 @@ -108,12 +120,7 @@ export const tourRepo = { return onlineThenCache( async () => { const result = await toursApi.detail(tripId, placeId, signal) - await offlineDb.tours.put({ - ...result.tour, - trip_id: Number(tripId), - waypoints: result.waypoints, - ...(result.provenance ? { provenance: result.provenance } : {}), - }) + await cacheTourAnswer(Number(tripId), result) return result }, async () => { @@ -124,7 +131,12 @@ export const tourRepo = { const waypoints = cached.waypoints?.length ? cached.waypoints : endpointsOf((await offlineDb.places.get(placeId))?.route_geometry) - return { tour: toListItem(cached), waypoints, ...(cached.provenance ? { provenance: cached.provenance } : {}) } + return { + tour: toListItem(cached), + waypoints, + ...(cached.provenance ? { provenance: cached.provenance } : {}), + ...(cached.obstacle_check ? { obstacle_check: cached.obstacle_check } : {}), + } }, ) }, @@ -155,7 +167,7 @@ export const tourRepo = { return { tour: toListItem(tour), waypoints: body.waypoints } } const result = await toursApi.create(tripId, body) - await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + await cacheTourAnswer(Number(tripId), result) return result }, @@ -163,10 +175,11 @@ export const tourRepo = { if (await mutationQueue.mustQueue('tours', placeId)) { const tid = Number(tripId) const existing = await offlineDb.tours.get(placeId) - const tour: CachedTour = { + const id = generateUUID() + const tour = staleCheck({ difficulty: null, wanderer_ref: null, planned: false, ...existing, ...routeFields(body), place_id: placeId, trip_id: tid, waypoints: body.waypoints, - } + }, id) const place = await offlineDb.places.get(placeId) await offlineDb.transaction('rw', offlineDb.places, offlineDb.tours, async () => { await offlineDb.places.put({ ...(place ?? {} as Place), id: placeId, trip_id: tid, ...routePlaceFields(body) }) @@ -174,7 +187,7 @@ export const tourRepo = { }) const isTemp = placeId < 0 await mutationQueue.enqueue({ - id: generateUUID(), + id, tripId: tid, method: 'PUT', url: isTemp ? `/trips/${tripId}/tours/{id}` : `/trips/${tripId}/tours/${placeId}`, @@ -187,7 +200,7 @@ export const tourRepo = { return { tour: toListItem(tour), waypoints: body.waypoints } } const result = await toursApi.update(tripId, placeId, body) - await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + await cacheTourAnswer(Number(tripId), result) return result }, @@ -208,16 +221,17 @@ export const tourRepo = { }) const moving = paddleMovingSeconds(estimate) const empty = body.track_marks.portages.length === 0 && body.track_marks.locks.length === 0 - const tour: CachedTour = { + const id = generateUUID() + const tour = staleCheck({ ...existing, paddle_speed_kmh: body.paddle_speed_kmh, portage_speed_kmh: body.portage_speed_kmh, track_marks: empty ? null : body.track_marks, duration: moving === null ? null : Math.round(moving / 60), - } + }, id) await offlineDb.tours.put(tour) await mutationQueue.enqueue({ - id: generateUUID(), + id, tripId: tid, method: 'PUT', url: `/trips/${tripId}/tours/${placeId}/paddle-plan`, @@ -229,14 +243,14 @@ export const tourRepo = { return { tour: toListItem(tour), waypoints: existing.waypoints ?? [] } } const result = await tourPaddlePlanApi.update(tripId, placeId, body) - await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + await cacheTourAnswer(Number(tripId), result) return result }, /** Online only, like the import: the recorded track is kept on the server, not in the cache. */ async restoreRecorded(tripId: number | string, placeId: number): Promise { const result = await recordedTrackApi.restore(tripId, placeId) - await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + await cacheTourAnswer(Number(tripId), result) return result }, @@ -246,7 +260,71 @@ export const tourRepo = { */ async copy(tripId: number | string, placeId: number, name: string): Promise { const result = await tourCopiesApi.copy(tripId, placeId, { name }) - await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + await cacheTourAnswer(Number(tripId), result) + return result + }, + + /** + * PAD-06: a paddle tour's checklist for the bench the list shows. The + * cached one serves while its revision matches; otherwise the detail is read + * (the cache standing in offline), and a checklist of another revision than + * the bench's reads as stale. + */ + async obstacleCheck( + tripId: number | string, + placeId: number, + revision: string | null, + signal?: AbortSignal, + ): Promise<{ check: TourObstacleCheck | null; stale: boolean }> { + const cached = await offlineDb.tours.get(placeId) + if (revision !== null && cached?.obstacle_check?.revision === revision) return { check: cached.obstacle_check, stale: false } + const check = (await tourRepo.detail(tripId, placeId, signal)).obstacle_check ?? null + return { check, stale: check !== null && revision !== null && check.revision !== revision } + }, + + /** + * PAD-06: what the paddlers decided about the obstacles. Made offline, or + * behind a queued write of the same tour, the cached checklist and bench + * take the change at once under a local revision and the write waits in the + * queue; the server then binds each decision to the basis the user saw. + */ + async saveObstacleReviews( + tripId: number | string, + placeId: number, + body: TourObstacleReviewsRequest, + userId: number | null, + ): Promise { + if (placeId < 0) throw new Error('Save the tour before deciding about its obstacles') + if (await mutationQueue.mustQueue('tours', placeId)) { + const existing = await offlineDb.tours.get(placeId) + if (!existing?.obstacle_check) throw new Error('Checklist not available offline') + const id = generateUUID() + const revision = `local:${id}` + const check = applyObstacleOps(existing.obstacle_check, body.ops, { now: new Date().toISOString(), userId, revision }) + const bench = existing.paddle_check + ? { ...paddleCheckSummaryOf(check.entries, check.own, existing.paddle_check), revision } + : existing.paddle_check + const tour: CachedTour = { ...existing, obstacle_check: check, paddle_check: bench } + await offlineDb.tours.put(tour) + await mutationQueue.enqueue({ + id, + tripId: Number(tripId), + method: 'PUT', + url: `/trips/${tripId}/tours/${placeId}/obstacle-reviews`, + body, + resource: 'tours', + entityId: placeId, + }) + mutationQueue.sendSoon() + return { + tour: toListItem(tour), + waypoints: existing.waypoints ?? [], + obstacle_check: check, + ...(existing.provenance ? { provenance: existing.provenance } : {}), + } + } + const result = await tourObstacleReviewsApi.save(tripId, placeId, body) + await cacheTourAnswer(Number(tripId), result) return result }, diff --git a/client/src/store/paddleObstacleStore.ts b/client/src/store/paddleObstacleStore.ts index 402800dff..cbd874e0e 100644 --- a/client/src/store/paddleObstacleStore.ts +++ b/client/src/store/paddleObstacleStore.ts @@ -1,6 +1,19 @@ -import type { PaddleCoverageRegion, PaddleCoverageState, PaddleFeature, RelatedObstacle } from '@trek/shared'; +import { + paddleGeometryPoints, + paddleObstacleKeyOf, + type PaddleCoverageRegion, + type PaddleCoverageState, + type PaddleFeature, + type RelatedObstacle, +} from '@trek/shared'; import { create } from 'zustand'; +/** A request to move the camera onto an area, numbered so the newest one wins. */ +export interface PaddleFocus { + seq: number; + points: [number, number][]; +} + /** Whether the obstacles around the open paddle tour are there to show, and if not, why. */ export type PaddleObstacleStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error'; @@ -17,6 +30,8 @@ interface PaddleObstacleState { truncated: boolean; /** The object whose card is open, by OSM reference; the map and the checklist show the same one. */ selectedRef: string | null; + /** Where the checklist last asked the camera to go (PAD-06): a click in the rail moves it, one on the map does not. */ + focus: PaddleFocus | null; } const EMPTY: PaddleObstacleState = { @@ -28,8 +43,12 @@ const EMPTY: PaddleObstacleState = { regions: [], truncated: false, selectedRef: null, + focus: null, }; +/** The margin round a focused area, in degrees: about 150 m, so an object never sits on the edge. */ +const FOCUS_MARGIN_DEG = 0.0015; + /** * PAD-05B: the paddle obstacles of the tour open in the planner, shared by * the map that draws them and the rails that list them. One sync hook writes @@ -37,7 +56,7 @@ const EMPTY: PaddleObstacleState = { */ export const usePaddleObstacleStore = create(() => EMPTY); -export function publishPaddleObstacles(next: Omit): void { +export function publishPaddleObstacles(next: Omit): void { usePaddleObstacleStore.setState((state) => ({ ...next, // A selection that is no longer among the objects is dropped with them. @@ -52,3 +71,38 @@ export function selectPaddleObstacle(ref: string | null): void { export function clearPaddleObstacles(): void { usePaddleObstacleStore.setState(EMPTY); } + +/** The corners of the points' bounds, widened by the margin. */ +function framed(points: readonly (readonly [number, number])[]): [number, number][] { + const lats = points.map(([lat]) => lat); + const lngs = points.map(([, lng]) => lng); + return [ + [Math.min(...lats) - FOCUS_MARGIN_DEG, Math.min(...lngs) - FOCUS_MARGIN_DEG], + [Math.max(...lats) + FOCUS_MARGIN_DEG, Math.max(...lngs) + FOCUS_MARGIN_DEG], + ]; +} + +/** Moves the camera onto an area, the newest request winning over a search result picked before it. */ +export function focusPaddleArea(points: readonly (readonly [number, number])[]): void { + if (points.length === 0) return; + usePaddleObstacleStore.setState((state) => ({ focus: { seq: (state.focus?.seq ?? 0) + 1, points: framed(points) } })); +} + +/** + * Selects an object and moves the camera onto it and the other parts of its + * lock; false when the map does not hold the object (offline, or gone from the data). + */ +export function focusPaddleObstacle(ref: string): boolean { + const { features } = usePaddleObstacleStore.getState(); + const feature = features.find((candidate) => candidate.ref === ref); + if (!feature) return false; + const key = paddleObstacleKeyOf(feature); + const points = features + .filter((candidate) => paddleObstacleKeyOf(candidate) === key) + .flatMap((candidate) => paddleGeometryPoints(candidate.geometry)); + usePaddleObstacleStore.setState((state) => ({ + selectedRef: ref, + focus: { seq: (state.focus?.seq ?? 0) + 1, points: framed(points) }, + })); + return true; +} diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 1bb1decbc..fb6500303 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -6,6 +6,7 @@ * online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie */ import { offlineDb, MUTATION_SCHEMA_VERSION } from '../db/offlineDb' +import { cacheTourAnswer } from '../db/cacheTour' import { apiClient } from '../api/client' import { isAuthed } from './authGate' import { isEffectivelyOffline } from './networkMode' @@ -428,7 +429,7 @@ async function reconcileTour(mutation: QueuedMutation, saved: TourCreateResponse await offlineDb.tours.delete(mutation.tempId) if (temp && !(await offlineDb.places.get(realId))) await offlineDb.places.put({ ...temp, id: realId }) } - await offlineDb.tours.put({ ...saved.tour, trip_id: mutation.tripId, waypoints: saved.waypoints }) + await cacheTourAnswer(mutation.tripId, saved) }) await remapTempId(mutation, realId, idMap) } diff --git a/client/tests/helpers/paddleCheck.ts b/client/tests/helpers/paddleCheck.ts new file mode 100644 index 000000000..6adb83e92 --- /dev/null +++ b/client/tests/helpers/paddleCheck.ts @@ -0,0 +1,99 @@ +/** + * A paddle tour's checklist and bench as the server answers them (PAD-06, + * PAD-09), for the client suites: a weir on the route, open unless told + * otherwise, with the basis a decision is bound to. + */ +import type { PaddleCheck, TourObstacleCheck, TourObstacleEntry, TourOwnEntry } from '@trek/shared'; + +export const ROUTE_BASIS = 'p1:o:4920000,1200400,w|4920000,1200500,w|4920000,1200600,w'; +export const SOURCE_BASIS = 's1:0123456789abcdef:4920000,1200500'; + +export function obstacleEntry(overrides: Partial = {}): TourObstacleEntry { + return { + key: 'node/1', + refs: ['node/1'], + kind: 'weir', + name: 'Wehr Regendorf', + at: [49.2, 12.005], + bounds: [ + [49.2, 12.005], + [49.2, 12.005], + ], + relation: 'on_route', + along_m: 1200, + distance_m: 0, + passages: 1, + access: { value: null, source: null, condition: null, forbidden: false, private: false }, + advice: null, + portage: 'none', + local_check: null, + data_as_of: '2026-10-01T00:00:00.000Z', + status: 'open', + changed: [], + basis: { route: ROUTE_BASIS, source: SOURCE_BASIS }, + decision: null, + ...overrides, + }; +} + +/** An earlier decision no current entry carries: about an object the data no longer holds. */ +export function earlierEntry(overrides: Partial = {}): TourObstacleEntry { + return obstacleEntry({ + key: 'node/9', + refs: ['node/9'], + name: 'Alte Sohlschwelle', + relation: null, + along_m: null, + distance_m: null, + passages: 0, + status: 'gone', + basis: null, + decision: { decision: 'pass', note: null, decided_at: '2026-09-20T10:00:00.000Z', decided_by: 1 }, + ...overrides, + }); +} + +export function ownPortage(overrides: Partial = {}): TourOwnEntry { + return { + kind: 'portage', + index: 1, + from_m: 1150, + to_m: 1260, + at: [49.2, 12.0052], + bounds: [ + [49.2, 12.0045], + [49.2001, 12.0058], + ], + covers: ['node/1'], + bypassed: false, + advice: null, + conflict: null, + ...overrides, + }; +} + +export function obstacleCheck(overrides: Partial = {}): TourObstacleCheck { + return { + revision: 'rev-1', + evaluated_at: '2026-10-16T08:00:00.000Z', + entries: [obstacleEntry()], + own: [], + ...overrides, + }; +} + +export function paddleBench(overrides: Partial = {}): PaddleCheck { + return { + data: 'covered', + data_as_of: '2026-10-01T00:00:00.000Z', + read_at: '2026-10-02T04:20:00.000Z', + entries: 1, + open: 1, + review: 0, + decided: 0, + bans: 0, + own: 0, + revision: 'rev-1', + ...overrides, + }; +} diff --git a/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts b/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts new file mode 100644 index 000000000..1775d9b3b --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.obstacleReviews.test.ts @@ -0,0 +1,147 @@ +/** + * mutationQueue replay of a paddle tour's decisions made offline (PAD-06): + * the server's checklist replaces the local one and provenance stays, a + * checklist this build cannot read never stalls the queue, a refused body is + * parked as failed and an obstacle data outage waits for a retry. + */ +import type { TourListItem } from '@trek/shared'; +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; +import { tourRepo } from '../../../src/repo/tourRepo'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { server } from '../../helpers/msw/server'; +import { obstacleCheck, obstacleEntry, paddleBench, ROUTE_BASIS, SOURCE_BASIS } from '../../helpers/paddleCheck'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +function paddleTour(overrides: Partial = {}): TourListItem { + return { + place_id: 40, + name: 'Regen', + tour_type: 'kayak', + distance: 8, + elevation_gain: null, + elevation_loss: null, + duration: 120, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: false, + caution: false, + has_waypoints: true, + paddle_check: paddleBench(), + ...overrides, + }; +} + +const waypoints = [ + { lat: 49.2, lng: 12, role: 'start' as const, sequence: 0 }, + { lat: 49.2, lng: 12.01, role: 'end' as const, sequence: 1 }, +]; +const provenance = { + origin: 'trek' as const, + createdAt: null, + source: null, + derivedFrom: null, + plannedOver: false, + editedLocally: null, +}; +const decide = { + ops: [ + { + key: 'node/1', + op: 'decide' as const, + decision: 'pass' as const, + note: null, + basis: { route: ROUTE_BASIS, source: SOURCE_BASIS }, + }, + ], +}; + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); + await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, provenance, obstacle_check: obstacleCheck() }); + vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined); + await tourRepo.saveObstacleReviews(3, 40, decide, 4); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('mutationQueue.flush: obstacle decisions', () => { + it('FE-SYNC-TOUR-010: a replayed decision caches the checklist the server answered and keeps provenance', async () => { + const answered = obstacleCheck({ revision: 'rev-2', entries: [obstacleEntry({ status: 'decided' })] }); + let key: string | null = null; + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', ({ request }) => { + key = request.headers.get('X-Idempotency-Key'); + return HttpResponse.json({ + tour: paddleTour({ paddle_check: paddleBench({ revision: 'rev-2' }) }), + waypoints, + obstacle_check: answered, + }); + }) + ); + const [queued] = await offlineDb.mutationQueue.toArray(); + setOnline(true); + + await mutationQueue.flush(); + + expect(key).toBe(queued?.id); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.tours.get(40)).toMatchObject({ + obstacle_check: answered, + provenance, + paddle_check: { revision: 'rev-2' }, + }); + }); + + it('FE-SYNC-TOUR-011: a checklist the answer carries that this build cannot read does not stall the queue', async () => { + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', () => + HttpResponse.json({ tour: paddleTour(), waypoints, obstacle_check: { entries: 'not a list' } }) + ) + ); + const local = (await offlineDb.tours.get(40))?.obstacle_check; + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(local); + }); + + it('FE-SYNC-TOUR-012: a refused body is parked as failed, an obstacle data outage waits for a retry', async () => { + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', () => + HttpResponse.json({ error: 'Obstacle data unavailable' }, { status: 503 }) + ) + ); + setOnline(true); + await mutationQueue.flush(); + expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ status: 'pending' }); + + mutationQueue._resetFlushing(); + await offlineDb.mutationQueue.toCollection().modify({ retryAfter: undefined }); + server.use( + http.put('/api/trips/3/tours/40/obstacle-reviews', () => + HttpResponse.json({ error: 'Only a paddle tour has an obstacle checklist' }, { status: 400 }) + ) + ); + await mutationQueue.flush(); + expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ status: 'failed' }); + }); +});