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>
196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { distanceKm } from "./geo";
|
|
import {
|
|
rankNearbyCustomers,
|
|
DEFAULT_RADIUS_KM,
|
|
type NearbyCandidate,
|
|
} from "./visit-matching";
|
|
|
|
/**
|
|
* Fixtures are anchored on Sibanye Driefontein (Carletonville) — the
|
|
* same part of the West Rand the real customer base sits in, so the
|
|
* latitude-dependent longitude scaling in `distanceKm` is exercised at
|
|
* a realistic value rather than at the equator.
|
|
*/
|
|
const ORIGIN = [-26.4, 27.4] as const;
|
|
|
|
/** ~1 km north of ORIGIN (1 degree of latitude ≈ 111.19 km). */
|
|
const ONE_KM_NORTH = { lat: -26.4 + 1 / 111.19, lon: 27.4 };
|
|
/** ~3 km north of ORIGIN — outside a 2 km radius. */
|
|
const THREE_KM_NORTH = { lat: -26.4 + 3 / 111.19, lon: 27.4 };
|
|
|
|
function candidate(over: Partial<NearbyCandidate> & { id: string }): NearbyCandidate {
|
|
return {
|
|
customer: `Customer ${over.id}`,
|
|
coordinates: { lat: ORIGIN[0], lon: ORIGIN[1] },
|
|
rep: "",
|
|
companyName: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("rankNearbyCustomers", () => {
|
|
it("returns customers inside the radius and drops those outside it", () => {
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [
|
|
candidate({ id: "near", coordinates: ONE_KM_NORTH }),
|
|
candidate({ id: "far", coordinates: THREE_KM_NORTH }),
|
|
],
|
|
});
|
|
|
|
expect(result.map((r) => r.id)).toEqual(["near"]);
|
|
});
|
|
|
|
it("skips Null Island customers rather than placing them off West Africa", () => {
|
|
// This is the state of all 616 customers before the pin tool /
|
|
// capture-on-visit runs, so it is the common case, not an edge case.
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [
|
|
candidate({ id: "unset", coordinates: { lat: 0, lon: 0 } }),
|
|
candidate({ id: "pinned" }),
|
|
],
|
|
});
|
|
|
|
expect(result.map((r) => r.id)).toEqual(["pinned"]);
|
|
});
|
|
|
|
it("skips customers whose coordinates field is missing entirely", () => {
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [
|
|
candidate({ id: "null-coords", coordinates: null }),
|
|
candidate({ id: "undef-coords", coordinates: undefined }),
|
|
candidate({ id: "pinned" }),
|
|
],
|
|
});
|
|
|
|
expect(result.map((r) => r.id)).toEqual(["pinned"]);
|
|
});
|
|
|
|
it("puts the signed-in rep's own customers first, then sorts by distance", () => {
|
|
// Inside a 2 km radius everyone is on the same mine property, so
|
|
// distance is mostly noise — the rep came to see *their* accounts.
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
repId: "rep-tiaan",
|
|
customers: [
|
|
candidate({ id: "other-closest", rep: "rep-chris" }),
|
|
candidate({ id: "mine-far", rep: "rep-tiaan", coordinates: ONE_KM_NORTH }),
|
|
candidate({ id: "mine-close", rep: "rep-tiaan" }),
|
|
],
|
|
});
|
|
|
|
expect(result.map((r) => r.id)).toEqual([
|
|
"mine-close",
|
|
"mine-far",
|
|
"other-closest",
|
|
]);
|
|
});
|
|
|
|
it("marks which rows belong to the signed-in rep", () => {
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
repId: "rep-tiaan",
|
|
customers: [
|
|
candidate({ id: "mine", rep: "rep-tiaan" }),
|
|
candidate({ id: "theirs", rep: "rep-chris" }),
|
|
],
|
|
});
|
|
|
|
expect(result.find((r) => r.id === "mine")?.isMine).toBe(true);
|
|
expect(result.find((r) => r.id === "theirs")?.isMine).toBe(false);
|
|
});
|
|
|
|
it("treats every row as not-mine when no rep is signed in", () => {
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [candidate({ id: "a", rep: "rep-tiaan" })],
|
|
});
|
|
|
|
expect(result[0].isMine).toBe(false);
|
|
});
|
|
|
|
it("reports distance in metres-precision kilometres", () => {
|
|
const [row] = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [candidate({ id: "a", coordinates: ONE_KM_NORTH })],
|
|
});
|
|
|
|
expect(row.distanceKm).toBeCloseTo(1, 2);
|
|
});
|
|
|
|
it("carries the company name through for display", () => {
|
|
const [row] = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 2,
|
|
customers: [
|
|
candidate({ id: "a", customer: "DANIE GREYLING", companyName: "Sibanye - Driefontein" }),
|
|
],
|
|
});
|
|
|
|
expect(row.name).toBe("DANIE GREYLING");
|
|
expect(row.companyName).toBe("Sibanye - Driefontein");
|
|
});
|
|
|
|
it("includes a customer sitting exactly on the radius boundary", () => {
|
|
// A rep parked exactly `radiusKm` from a pin should still see it —
|
|
// excluding the boundary makes the radius setting feel arbitrary.
|
|
// The radius is taken from the measured distance so this asserts the
|
|
// exact `<=` boundary rather than an approximated one.
|
|
const exactDistance = distanceKm(ORIGIN, [
|
|
ONE_KM_NORTH.lat,
|
|
ONE_KM_NORTH.lon,
|
|
]);
|
|
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: exactDistance,
|
|
customers: [candidate({ id: "boundary", coordinates: ONE_KM_NORTH })],
|
|
});
|
|
|
|
expect(result.map((r) => r.id)).toEqual(["boundary"]);
|
|
});
|
|
|
|
it("excludes a customer just outside the radius", () => {
|
|
const exactDistance = distanceKm(ORIGIN, [
|
|
ONE_KM_NORTH.lat,
|
|
ONE_KM_NORTH.lon,
|
|
]);
|
|
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: exactDistance - 0.001,
|
|
customers: [candidate({ id: "boundary", coordinates: ONE_KM_NORTH })],
|
|
});
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("falls back to the default radius when given a non-positive one", () => {
|
|
// A misconfigured settings row must not silently match every
|
|
// customer in the country (radius 0 → nothing, negative → nothing).
|
|
const result = rankNearbyCustomers({
|
|
origin: ORIGIN,
|
|
radiusKm: 0,
|
|
customers: [candidate({ id: "near", coordinates: ONE_KM_NORTH })],
|
|
});
|
|
|
|
expect(DEFAULT_RADIUS_KM).toBe(2);
|
|
expect(result.map((r) => r.id)).toEqual(["near"]);
|
|
});
|
|
|
|
it("returns an empty list rather than throwing on no candidates", () => {
|
|
expect(
|
|
rankNearbyCustomers({ origin: ORIGIN, radiusKm: 2, customers: [] })
|
|
).toEqual([]);
|
|
});
|
|
});
|