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

110 lines
3.8 KiB
TypeScript

/**
* Rep visit check-in — "which customers am I standing at?"
*
* Pure matching logic, no PocketBase and no React, so it unit-tests in
* isolation and can run on either side of the wire. The server
* (`lib/server/visits-service.ts`) feeds it customer rows; the phone
* renders what comes back.
*
* Deliberately radius-based rather than site/geofence-based: WFDASH has
* no "site" entity, and `companies` can't stand in for one (it's
* polluted with rows auto-created from email intake that are actually
* people). A point plus a radius needs no new taxonomy and no data
* cleanup to start working.
*/
import { distanceKm, isRealGeoPoint, type GeoPoint, type LatLng } from "./geo";
/**
* Default match radius in kilometres.
*
* 2 km covers a sprawling mine property — a rep parked at the Sibanye
* Driefontein gate still matches customers pinned at the workshop —
* without reaching the next operation down the road. Operators can
* override it via the `rep_visit_settings` singleton; this constant is
* the floor the code falls back to when that row is missing or invalid.
*/
export const DEFAULT_RADIUS_KM = 2;
/** A customer row as the matcher needs it — a narrowed `Customer`. */
export interface NearbyCandidate {
id: string;
/** The contact's name (PB column is `customer`, not `name`). */
customer: string;
coordinates?: GeoPoint | null;
/** PB relation id → `users`. Empty string when unassigned. */
rep?: string;
/** Resolved from `expand.company.company` by the caller. */
companyName?: string | null;
}
export interface NearbyCustomer {
id: string;
name: string;
companyName: string | null;
/** Great-circle distance from the check-in point, in km. */
distanceKm: number;
/** True when this customer is assigned to the rep who is checking in. */
isMine: boolean;
}
export interface RankNearbyArgs {
/** Where the vehicle is, as `[lat, lng]`. */
origin: LatLng;
customers: NearbyCandidate[];
/** Match radius in km. Non-positive values fall back to the default. */
radiusKm?: number;
/** PB user id of the rep checking in. Omit for an unscoped lookup. */
repId?: string;
}
/**
* Returns the customers within `radiusKm` of `origin`, the rep's own
* accounts first and each group sorted nearest-first.
*
* Own-first rather than strictly nearest-first: inside a 2 km radius
* every candidate is on the same property, so distance is mostly noise
* — the rep drove out to see *their* accounts, and burying those under
* a colleague's contact who happens to be 40 m closer would make the
* list feel wrong. `distanceKm` is still returned so the UI can show it,
* and `isMine` is exposed so the UI can badge the split.
*
* Customers without a real position are skipped, not sorted to the
* bottom: with every `coordinates` column defaulting to Null Island,
* including them would put the entire customer base 3,000 km off the
* coast of Ghana. The rep reaches those through the search box instead,
* which is also how they get pinned in the first place.
*/
export function rankNearbyCustomers({
origin,
customers,
radiusKm,
repId,
}: RankNearbyArgs): NearbyCustomer[] {
const limitKm = radiusKm && radiusKm > 0 ? radiusKm : DEFAULT_RADIUS_KM;
const matches: NearbyCustomer[] = [];
for (const row of customers) {
if (!isRealGeoPoint(row.coordinates)) continue;
const distance = distanceKm(origin, [
row.coordinates.lat,
row.coordinates.lon,
]);
if (distance > limitKm) continue;
matches.push({
id: row.id,
name: row.customer,
companyName: row.companyName ?? null,
distanceKm: distance,
isMine: Boolean(repId) && row.rep === repId,
});
}
return matches.sort((a, b) => {
if (a.isMine !== b.isMine) return a.isMine ? -1 : 1;
return a.distanceKm - b.distanceKm;
});
}