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>
110 lines
3.8 KiB
TypeScript
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;
|
|
});
|
|
}
|