WFDASH-WEBAPP/src/lib/visit-stops.ts
Dave 6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:22:28 +02:00

183 lines
5.8 KiB
TypeScript

/**
* 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<number>();
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,
};
}