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>
210 lines
7.4 KiB
TypeScript
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));
|
|
}
|