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>
183 lines
5.8 KiB
TypeScript
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,
|
|
};
|
|
}
|