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>
201 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|