/** * Stop detection — turns a vehicle's breadcrumb trail into "the rep was * parked here, from this time to that time". * * This is the bridge between the two halves of the feature. FleetAI has * no position-history endpoint, so WFDASH polls current positions on a * schedule and keeps its own trail (`rep_vehicle_positions`). Raw * breadcrumbs are pruned after 35 days; the stops derived from them are * kept indefinitely, because a stop is small, stable, and is what every * report actually asks about — time spent per site, and "which visits * did the rep forget to log?". * * Pure and side-effect free: no PocketBase, no clock, no network. * * ## What this can and cannot tell you * * Polling *samples* a position every few minutes; it does not replay a * route. Two consequences are baked into the output rather than hidden: * * - Duration is measured between the **first and last fix** of a stop, * so it always slightly *understates* the true dwell — the vehicle * arrived before the first fix and left after the last. * - A tracker that loses signal (underground, in a shed) produces a * gap that looks exactly like a long stop. `longestGapMinutes` * exposes that so a report can flag a stop as uncertain instead of * asserting a confident three-hour visit. */ import { distanceKm, type LatLng } from "./geo"; /** * Metres from the stop's anchor point that still counts as "the same * place". 150 m absorbs the tens of metres a stationary GPS wanders * between fixes while staying well inside one parking area. */ export const DEFAULT_STOP_RADIUS_METRES = 150; /** * Minutes below which a cluster isn't a visit. 5 minutes filters out * gate queues and robots without discarding a genuine quick drop-off. */ export const DEFAULT_MIN_STOP_MINUTES = 5; export interface Breadcrumb { /** ISO-8601. Rows with an unparseable value are skipped. */ fixedAt: string; lat: number; lon: number; /** Null when the device has no ignition wire — not the same as "off". */ ignition?: boolean | null; speedKmh?: number | null; status?: string | null; } export interface Stop { startedAt: string; endedAt: string; /** First fix → last fix. Understates the true dwell; see the file docs. */ durationMinutes: number; /** Centroid of the stop's fixes. */ lat: number; lon: number; fixCount: number; /** * True only when *every* fix reported ignition off. Unknown (`null`, * i.e. FleetAI's `--`) counts as false: a stop we can't corroborate * shouldn't claim the extra confidence. */ ignitionOff: boolean; /** * Largest interval between consecutive fixes inside this stop. A large * value means the trail has a hole and the duration is a guess. */ longestGapMinutes: number; } export interface DetectStopsOptions { radiusMetres?: number; minDurationMinutes?: number; } /** A breadcrumb that survived validation, with its parsed timestamp. */ type ValidFix = { crumb: Breadcrumb; at: number }; export function detectStops( crumbs: Breadcrumb[], options: DetectStopsOptions = {} ): Stop[] { const radiusKm = (options.radiusMetres ?? DEFAULT_STOP_RADIUS_METRES) / 1000; const minDurationMinutes = options.minDurationMinutes ?? DEFAULT_MIN_STOP_MINUTES; const fixes = normalise(crumbs); if (fixes.length < 2) return []; const stops: Stop[] = []; let current: ValidFix[] = [fixes[0]]; // The anchor is the stop's *first* fix, not a running centroid. // Comparing against a centroid lets the reference point drift along // with a slow-moving vehicle, chaining a 500 m crawl into a single // "stationary" stop. let anchor: LatLng = [fixes[0].crumb.lat, fixes[0].crumb.lon]; for (let i = 1; i < fixes.length; i++) { const fix = fixes[i]; const here: LatLng = [fix.crumb.lat, fix.crumb.lon]; if (distanceKm(anchor, here) <= radiusKm) { current.push(fix); continue; } const closed = buildStop(current, minDurationMinutes); if (closed) stops.push(closed); current = [fix]; anchor = here; } const last = buildStop(current, minDurationMinutes); if (last) stops.push(last); return stops; } /** * Drops fixes with an unparseable timestamp, de-duplicates fixes landing * on the same instant, and sorts chronologically. * * Sorting is defensive rather than decorative: PocketBase paging does * not guarantee order across pages, and a poller retry can insert an * older fix after a newer one. An out-of-order trail would otherwise * produce negative durations. */ function normalise(crumbs: Breadcrumb[]): ValidFix[] { const seen = new Set(); const fixes: ValidFix[] = []; for (const crumb of crumbs) { const at = new Date(crumb.fixedAt).getTime(); if (Number.isNaN(at)) continue; if (seen.has(at)) continue; seen.add(at); fixes.push({ crumb, at }); } return fixes.sort((a, b) => a.at - b.at); } /** Closes a cluster into a Stop, or returns null if it's too short. */ function buildStop(fixes: ValidFix[], minDurationMinutes: number): Stop | null { if (fixes.length < 2) return null; const first = fixes[0]; const last = fixes[fixes.length - 1]; const durationMinutes = (last.at - first.at) / 60_000; if (durationMinutes < minDurationMinutes) return null; let latSum = 0; let lonSum = 0; let longestGapMs = 0; let ignitionOff = true; for (let i = 0; i < fixes.length; i++) { const { crumb, at } = fixes[i]; latSum += crumb.lat; lonSum += crumb.lon; if (crumb.ignition !== false) ignitionOff = false; if (i > 0) longestGapMs = Math.max(longestGapMs, at - fixes[i - 1].at); } return { startedAt: new Date(first.at).toISOString(), endedAt: new Date(last.at).toISOString(), durationMinutes, lat: latSum / fixes.length, lon: lonSum / fixes.length, fixCount: fixes.length, ignitionOff, longestGapMinutes: longestGapMs / 60_000, }; }