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' }); + }); +});