mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<TourDetailResponse> =>
|
||||
apiClient
|
||||
.put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body)
|
||||
.then((r) => tourDetailResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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, string | number>) => 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<TourObstacleEntry, 'at' | 'bounds'>): [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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TrackPaddlePlan, 'dirty' | 'startPick' | '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<Loaded | null>(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<string | null>(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<typeof usePaddleChecklist>;
|
||||
|
||||
/** 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<typeof useChecklistRead>
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<CachedTour> {
|
||||
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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<LocationPoint | null>(null);
|
||||
const [searchCameraIntent, setSearchCameraIntent] = useState<SearchCameraIntent | null>(null);
|
||||
const searchCameraSequenceRef = useRef(0);
|
||||
const [intent, setIntent] = useState<CameraIntent | null>(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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
+98
-20
@@ -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<TourDetailResponse> {
|
||||
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<TourCreateResponse> {
|
||||
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<TourDetailResponse> {
|
||||
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
|
||||
},
|
||||
|
||||
|
||||
@@ -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<PaddleObstacleState>(() => EMPTY);
|
||||
|
||||
export function publishPaddleObstacles(next: Omit<PaddleObstacleState, 'selectedRef'>): void {
|
||||
export function publishPaddleObstacles(next: Omit<PaddleObstacleState, 'selectedRef' | 'focus'>): 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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): TourObstacleCheck {
|
||||
return {
|
||||
revision: 'rev-1',
|
||||
evaluated_at: '2026-10-16T08:00:00.000Z',
|
||||
entries: [obstacleEntry()],
|
||||
own: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function paddleBench(overrides: Partial<PaddleCheck> = {}): 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,
|
||||
};
|
||||
}
|
||||
@@ -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> = {}): 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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user