WFDASH-WEBAPP/src/lib/visit-stops.test.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

201 lines
6.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
detectStops,
DEFAULT_STOP_RADIUS_METRES,
DEFAULT_MIN_STOP_MINUTES,
type Breadcrumb,
} from "./visit-stops";
/** Sibanye Driefontein-ish, matching the rest of the geo fixtures. */
const BASE_LAT = -26.4;
const BASE_LON = 27.4;
/** Metres → degrees of latitude (1° ≈ 111,195 m on a 6371 km sphere). */
function metresNorth(metres: number): number {
return BASE_LAT + metres / 111195;
}
function crumb(
minutesFromStart: number,
over: Partial<Breadcrumb> = {}
): Breadcrumb {
return {
fixedAt: new Date(
Date.UTC(2026, 8, 4, 7, 0, 0) + minutesFromStart * 60_000
).toISOString(),
lat: BASE_LAT,
lon: BASE_LON,
ignition: false,
speedKmh: 0,
...over,
};
}
describe("detectStops", () => {
it("returns nothing for an empty trail", () => {
expect(detectStops([])).toEqual([]);
});
it("returns nothing for a single fix — a point has no duration", () => {
expect(detectStops([crumb(0)])).toEqual([]);
});
it("collapses a parked vehicle into one stop", () => {
const stops = detectStops([crumb(0), crumb(5), crumb(10), crumb(15)]);
expect(stops).toHaveLength(1);
expect(stops[0]).toMatchObject({
durationMinutes: 15,
fixCount: 4,
startedAt: "2026-09-04T07:00:00.000Z",
endedAt: "2026-09-04T07:15:00.000Z",
});
});
it("absorbs GPS jitter rather than splitting a stop into several", () => {
// A stationary tracker wanders tens of metres between fixes. Without
// a radius this would read as four separate visits.
const stops = detectStops([
crumb(0, { lat: metresNorth(0) }),
crumb(5, { lat: metresNorth(40) }),
crumb(10, { lat: metresNorth(-30) }),
crumb(15, { lat: metresNorth(25) }),
]);
expect(stops).toHaveLength(1);
expect(stops[0].fixCount).toBe(4);
});
it("does not invent a stop for a vehicle driving straight through", () => {
const stops = detectStops([
crumb(0, { lat: metresNorth(0), speedKmh: 60, ignition: true }),
crumb(5, { lat: metresNorth(4000), speedKmh: 80, ignition: true }),
crumb(10, { lat: metresNorth(9000), speedKmh: 75, ignition: true }),
]);
expect(stops).toEqual([]);
});
it("splits two visits separated by travel", () => {
const stops = detectStops([
crumb(0),
crumb(20),
// 5 km away — a different site.
crumb(60, { lat: metresNorth(5000) }),
crumb(90, { lat: metresNorth(5000) }),
]);
expect(stops).toHaveLength(2);
expect(stops[0].durationMinutes).toBe(20);
expect(stops[1].durationMinutes).toBe(30);
});
it("drops stops shorter than the minimum — a gate queue is not a visit", () => {
const stops = detectStops(
[crumb(0), crumb(3)],
{ minDurationMinutes: 10 }
);
expect(stops).toEqual([]);
});
it("sorts an out-of-order trail before analysing it", () => {
// PocketBase paging does not guarantee order, and a poller retry can
// insert an older fix after a newer one.
const stops = detectStops([crumb(15), crumb(0), crumb(10), crumb(5)]);
expect(stops).toHaveLength(1);
expect(stops[0].startedAt).toBe("2026-09-04T07:00:00.000Z");
expect(stops[0].endedAt).toBe("2026-09-04T07:15:00.000Z");
});
it("reports the centroid of the fixes as the stop position", () => {
const stops = detectStops([
crumb(0, { lat: metresNorth(0) }),
crumb(10, { lat: metresNorth(100) }),
]);
expect(stops[0].lat).toBeCloseTo(metresNorth(50), 6);
expect(stops[0].lon).toBeCloseTo(BASE_LON, 6);
});
it("measures against the stop's anchor so a slow crawl cannot chain into one stop", () => {
// Each fix is 100 m from the previous one — inside the radius — but
// the vehicle covers 500 m overall. A running-centroid comparison
// would drift along with it and report one long stationary stop.
const stops = detectStops([
crumb(0, { lat: metresNorth(0) }),
crumb(10, { lat: metresNorth(100) }),
crumb(20, { lat: metresNorth(200) }),
crumb(30, { lat: metresNorth(300) }),
crumb(40, { lat: metresNorth(400) }),
crumb(50, { lat: metresNorth(500) }),
]);
expect(stops.length).toBeGreaterThan(1);
});
it("flags the longest reporting gap inside a stop", () => {
// Underground or in a signal shadow the tracker goes quiet. Two fixes
// two hours apart at the same place *probably* mean the vehicle sat
// there — but it could also have left and come back. The report has
// to be able to say so rather than assert a confident 2-hour visit.
const stops = detectStops([crumb(0), crumb(120), crumb(125)]);
expect(stops).toHaveLength(1);
expect(stops[0].longestGapMinutes).toBe(120);
});
it("reports a zero gap for evenly-polled stops", () => {
const stops = detectStops([crumb(0), crumb(5), crumb(10)]);
expect(stops[0].longestGapMinutes).toBe(5);
});
it("marks a stop as ignition-off only when every fix agrees", () => {
const parked = detectStops([
crumb(0, { ignition: false }),
crumb(15, { ignition: false }),
]);
expect(parked[0].ignitionOff).toBe(true);
const idling = detectStops([
crumb(0, { ignition: false }),
crumb(15, { ignition: true }),
]);
expect(idling[0].ignitionOff).toBe(false);
});
it("reports ignitionOff as false when the device has no ignition wire", () => {
// `--` from FleetAI parses to null. Unknown must not read as "parked",
// which would overstate confidence in the visit.
const stops = detectStops([
crumb(0, { ignition: null }),
crumb(15, { ignition: null }),
]);
expect(stops[0].ignitionOff).toBe(false);
});
it("ignores fixes with an unparseable timestamp", () => {
const stops = detectStops([
crumb(0),
{ ...crumb(5), fixedAt: "not a date" },
crumb(15),
]);
expect(stops).toHaveLength(1);
expect(stops[0].fixCount).toBe(2);
});
it("de-duplicates repeated fixes at the same instant", () => {
// The poller keys on fix time, but a retry or a clock wobble can
// still land the same fix twice.
const stops = detectStops([crumb(0), crumb(0), crumb(15)]);
expect(stops[0].fixCount).toBe(2);
});
it("exposes usable defaults", () => {
expect(DEFAULT_STOP_RADIUS_METRES).toBe(150);
expect(DEFAULT_MIN_STOP_MINUTES).toBe(5);
});
});