WFDASH-WEBAPP/src/lib/tracker-parsing.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

210 lines
7.4 KiB
TypeScript

/**
* Netstar FleetAI response parsing — pure, no network.
*
* Split out from `lib/server/tracker-service.ts` because FleetAI has
* three response conventions that are easy to get wrong and worth
* pinning down with tests rather than discovering in production:
*
* 1. `--` (double dash) means "unavailable / not configured". It is
* *not* null, and it is not an error — §1.1 of the integration
* guide says to handle it explicitly. Rendered raw it shows up in
* the UI as a literal "--".
* 2. Errors come back with **HTTP 200** and a failure envelope in the
* body (§12). `response.ok` tells you nothing.
* 3. Timestamps are in *the platform's configured timezone* (§11),
* which is UTC+2 for South Africa — not UTC, and with no offset
* marker in the string. Parsing them naively puts every fix two
* hours in the past, which would make every fresh check-in look
* stale and trip the staleness warning every single time.
*/
/** South African Standard Time — the platform timezone per §10.1 step 6. */
export const SAST_OFFSET_MINUTES = 120;
/**
* Normalises a FleetAI scalar: `--`, empty and whitespace-only all mean
* "no value". Anything else is returned trimmed.
*
* Only the exact string `--` is treated as the sentinel — a value that
* merely contains a dash ("Sibanye - Driefontein") is real data.
*/
export function fleetAiValue(raw: unknown): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
if (s === "" || s === "--") return null;
return s;
}
/**
* Numeric variant of `fleetAiValue`. Tolerates a unit suffix because
* `gps.speed` comes back as e.g. `"62km/h"` (§4.2) while the top-level
* `Speed` is a bare number-as-string.
*
* Returns null rather than NaN so callers can't accidentally propagate
* NaN into a distance calculation.
*/
export function fleetAiNumber(raw: unknown): number | null {
const s = fleetAiValue(raw);
if (s === null) return null;
const match = s.match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const n = Number(match[0]);
return Number.isFinite(n) ? n : null;
}
/**
* Reads a FleetAI ON/OFF flag (`IGN`, `AC`, `Door1`…) as a boolean.
*
* Returns null for `--`, which per §1.1 means "not configured on this
* device" — a bakkie with no ignition wire must not be read as
* "ignition off", because ignition-off is the signal the stop detector
* uses to distinguish a real visit from a traffic queue.
*/
export function fleetAiBoolean(raw: unknown): boolean | null {
const s = fleetAiValue(raw);
if (s === null) return null;
const upper = s.toUpperCase();
if (upper === "ON" || upper === "TRUE" || upper === "1" || upper === "YES") {
return true;
}
if (upper === "OFF" || upper === "FALSE" || upper === "0" || upper === "NO") {
return false;
}
return null;
}
/**
* Parses a FleetAI timestamp into an ISO-8601 UTC string.
*
* Accepts, in order:
* - an ISO string carrying its own zone (returned as-is, normalised)
* - `dd-MM-yyyy HH:mm[:ss]` — the documented request format (§11)
* - `yyyy-MM-dd HH:mm[:ss]` — used by the driver-master and geofence
* endpoints, and seen in some live-data responses
*
* `offsetMinutes` is the platform timezone's offset from UTC (+120 for
* SAST) and is applied to the two zone-less formats. Returns null for
* the `--` sentinel and for anything that doesn't parse — a wrong date
* is worse than a missing one here, because it feeds the staleness
* check that decides whether a check-in is trustworthy.
*/
export function parseFleetAiTimestamp(
raw: unknown,
offsetMinutes: number
): string | null {
const s = fleetAiValue(raw);
if (s === null) return null;
// Already carries a zone (trailing Z or ±HH:MM) — trust it.
if (/T.*(?:Z|[+-]\d{2}:?\d{2})$/.test(s)) {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
const m = s.match(
/^(\d{2,4})[-/](\d{1,2})[-/](\d{2,4})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?$/
);
if (!m) return null;
const [, aRaw, bRaw, cRaw, hhRaw, miRaw, ssRaw] = m;
// `yyyy-MM-dd` when the first component is 4 digits, otherwise the
// documented `dd-MM-yyyy`. FleetAI never emits MM-dd-yyyy, so 04-09
// is unambiguously 4 September.
const isYearFirst = aRaw.length === 4;
const year = Number(isYearFirst ? aRaw : cRaw);
const month = Number(bRaw);
const day = Number(isYearFirst ? cRaw : aRaw);
const hours = Number(hhRaw);
const minutes = Number(miRaw);
const seconds = Number(ssRaw ?? "0");
// Explicit range checks: Date.UTC happily rolls month 13 over into
// January of the following year, which would silently produce a
// plausible-looking but wrong timestamp.
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
if (hours > 23 || minutes > 59 || seconds > 59) return null;
const utcMs = Date.UTC(year, month - 1, day, hours, minutes, seconds);
const d = new Date(utcMs);
if (Number.isNaN(d.getTime())) return null;
// Reject dates that rolled over (e.g. 31 February → 3 March).
if (d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) return null;
return new Date(utcMs - offsetMinutes * 60_000).toISOString();
}
/** Messages FleetAI returns in a 200 body that actually mean "failed". */
const ERROR_MARKERS = [
"invalid",
"not allowed",
"not belongs",
"no company found",
"no vehicle found",
"incorrect user name",
"something went wrong",
"token expired",
"expired",
"exceeded the limit",
"please wait for",
"should not be greater",
"no more than",
"do not have rights",
"unauthorized",
"multiple project rights",
"fail",
];
/**
* Returns the error message when a 200 response is actually a failure,
* or null when it looks like a success.
*
* FleetAI is inconsistent about the envelope — `{RESULT, MSG}`,
* `{result, message}` and `{root: {error}}` all appear in the guide —
* so all three are checked. A `result` of `"FAIL"` is definitive; when
* there's no result field we fall back to matching the message against
* the documented error strings (§12.1), which is why `"User is Active"`
* and the literal placeholder `"sample"` are not treated as errors.
*/
export function readFleetAiError(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const root = b.root as Record<string, unknown> | undefined;
if (root && typeof root === "object") {
const rootError = fleetAiValue(root.error);
if (rootError) return rootError;
}
const result = fleetAiValue(b.RESULT ?? b.result);
const message = fleetAiValue(b.MSG ?? b.msg ?? b.message ?? b.Message);
if (result && result.toUpperCase() === "FAIL") {
return message ?? "FleetAI request failed";
}
if (message && !result) {
const lower = message.toLowerCase();
if (ERROR_MARKERS.some((marker) => lower.includes(marker))) return message;
}
return null;
}
/**
* Seconds between a GPS fix and `now`, clamped at 0.
*
* Clamped because clock skew between the FleetAI platform and this
* server is routine, and a negative age would surface in the rep's UI
* as a fix "-3 minutes ago".
*/
export function fixAgeSeconds(
fixedAtIso: string | null,
now: Date = new Date()
): number | null {
if (!fixedAtIso) return null;
const fixed = new Date(fixedAtIso).getTime();
if (Number.isNaN(fixed)) return null;
return Math.max(0, Math.round((now.getTime() - fixed) / 1000));
}