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:
Maurice
2026-10-17 00:29:26 +00:00
parent fa8272c522
commit 7fffd4300f
23 changed files with 1389 additions and 57 deletions
@@ -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;