WFDASH-WEBAPP/docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md
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

18 KiB
Raw Blame History

Rep Visit Check-In — Design & Implementation Plan

Revised after a deliberate over-engineering pass. See "What was cut" at the end for what came out and why — those things were removed on purpose; don't reintroduce them.


⛔ State of play — read this first

Four modules are already built, tested and merged into the working tree. Do not rewrite them:

File What it does Tests
src/lib/visit-matching.ts rankNearbyCustomers — radius match, own-customers-first 13
src/lib/visit-stops.ts detectStops — trail → stops 17
src/lib/tracker-parsing.ts FleetAI response decoding 30
src/lib/server/tracker-service.ts FleetAiAdapter + FakeTrackerAdapter 32
src/lib/geo.ts isRealGeoPoint added (Null-Island guard) —
scripts/fleetai-probe.mjs Throwaway API probe, read-only —

102 tests passing, tsc --noEmit clean on all of them.

The suite has 9 pre-existing failing test files (sidebar, pocketbase-admin, invoicing, orders-service, delivery-notes-service, quotes/[id]/submit, orders route, po-agent-workflow, mcp route). Verified identical on a stashed tree — they are not caused by this work. Don't chase them.

Blocked, and on whom

  1. PocketBase schema — blocked on the user. The three collections and one field below are UI-only work in the PB admin. Per AGENTS.md the API 500s on anything that doesn't exist yet. Nothing that writes data can be built until these exist.
  2. FleetAI credentials — blocked on the user. Until then getTrackerAdapter() auto-selects FakeTrackerAdapter, so everything is still buildable and testable; it just talks to a simulated vehicle parked on the West Rand.

What can be built right now, unblocked

  • The office pin tool. It only writes customers.coordinates, which already exists. This is also the highest-value unblocked work: it's how the ~20 main shafts get seeded.
  • The rep PWA shell — (rep) route group, manifest-rep.json, sw-rep.js, the pickSwPath() branch, and the check-in screen reading from getCheckInContext against the fake adapter. It can render everything; it just can't save a visit yet.
  • vehicle-trail-service.ts's pure logic — the dedupe decision and the rollup, unit-tested against fixtures, with the PocketBase writes stubbed until the collections land.

Start with the pin tool.


Context

Westcott's sales reps drive to customer sites — mostly West Rand mine shafts (Harmony, Sibanye Driefontein/Kroondal, Northam Platinum, Redpath, Blyvoor, South Deep). Today there is no record of who was seen, where, or when. The business has a Netstar FleetAI account on its vehicles. The goal: the rep taps "I'm on site", the app uses the vehicle's position to show which customers are here, the rep ticks who they actually saw, and it's logged against the customer.

The finding that shapes everything

I scanned the live PocketBase instance. 0 of 616 customers and 1 of 445 companies have real coordinates — every coordinates geoPoint is {lon: 0, lat: 0} (Null Island). The fields exist; the data does not.

Auto-geocoding won't rescue this: of the 208 companies with any address text, most are P.O. Boxes, which geocode to a post office, not a mine.

So the app builds its own map. First visit: the rep searches the customer by name and ticks them, and we stamp the vehicle's position onto customers.coordinates. Second visit: they appear in the nearby list automatically. The visit log and the coordinate database come from the same tap. An office pin tool seeds the ~20 main shafts up front so day one isn't an empty screen.

Decisions

Decision Choice
Separate app? No. New (rep) route group in WFDASH, installed as a PWA — the third instance of a pattern already running here (/m, /counter)
Position source Vehicle tracker
Site model Radius around a point. No sites/shafts entity
Match against customers.coordinates (companies is polluted with rows auto-created from email intake that are actually people)
Rep → vehicle users.tracker_vehicle_id text field
Radius 2 km constant, plus a "widen search" button on the phone
Coordinate seeding Capture-on-visit + office pin tool
Office view /visits sidebar item under Management
Offline Not built. The breadcrumb trail makes it unnecessary — see below

The two halves

1. The breadcrumb poller (invisible). Every 5 minutes during working hours, one API call records where every rep vehicle is. This is our own position history, because FleetAI has none.

2. The check-in (rep's phone + office page). The rep logs who they saw; we pin coordinates as a side effect.

They're independent. The poller ships first and alone.


What already exists — reuse, don't rebuild

  • src/lib/geo.ts — distanceKm (Haversine) is the entire "who's near this point" maths.
  • PWA-per-route-group, proven twice — (mobile)/m and (counter), each with its own manifest + service worker. Copy the shape.
  • MobileAuthGate — reuse verbatim.
  • pickSwPath() — add one /rep branch.
  • customers-service.ts — listCustomers backs the phone's search box.
  • agent-auth.ts — assertAgentAuth, Bearer AGENT_API_KEY. The poll route uses it, like the existing cron workflows.
  • isRepUser — scopes /visits without a new permission concept.
  • Leaflet + react-leaflet — already used by supplier-map; the pin tool copies it.
  • driver_locations (54k rows) — precedent for this data volume. Note its geoPoint column is all-zero while latitude/longitude carry the real values. Don't repeat that split — write one geoPoint.

The tracker: Netstar FleetAI

Base URL https://fleetai.netstar.co.za. Guide v2.0, June 2026.

Need Endpoint Notes
Auth generateAccessToken 30-min token, sent as an auth-code header (not Bearer). Refresh at 25 min.
Vehicle list viewVehicle {project_id: 37} → plate_no, imei_no. Populates tracker_vehicle_id.
Check-in position getVehicleCurrentLocation {vehicle_no} → lat, lon, speed, timestamp. 10-second rate limit.
Poller position getTokenBaseLiveData Whole fleet in one call, 1-min limit. Returns GPSActualTime (true fix time) + IGN.

Three quirks that drive real code (all tested in src/lib/tracker-parsing.test.ts):

  1. Errors arrive as HTTP 200 with a failure envelope. response.ok tells you nothing.
  2. -- means "unavailable", not null — and for IGN, not false either. A bakkie with no ignition wire isn't "parked".
  3. Timestamps are UTC+2 with no offset marker. Parsed naively every fresh fix looks two hours old and trips the staleness warning every single time.

FleetAI has no position-history endpoint. Every position call is current-only; getAlertData is configured alerts, getPOIVisitData is a daily aggregate that can't be re-read for the same date, and getDeviceDistance gives only a window's endpoints. Hence the poller.

The account must be Company or Company Sub-Account level — Admin and Reseller logins are rejected outright.


⚠️ Do this before writing any more code

Three assumptions can only be settled by one real API call. Getting them wrong wastes far more than the ten minutes this costs:

  1. Does each rep have a dedicated vehicle in FleetAI, under a registration we can map to a user? If Chris and Tiaan share a bakkie, users.tracker_vehicle_id is the wrong model and the whole check-in flow changes shape.
  2. Does getVehicleCurrentLocation return GPSActualTime, or only timestamp (platform receive time)? The docs list only the latter. The adapter already prefers the fix time and falls back, but it changes how honest the staleness warning is.
  3. What is the exact company_name? A mismatch returns "No Company Found For Given User", which looks like "no vehicles today".

Step one is a throwaway script: authenticate, call viewVehicle, call getTokenBaseLiveData, print both. Then build.


PocketBase schema (you must add these in PB admin first)

Per AGENTS.md, PB schema is UI-only. Three collections and one field — nothing else.

1. Field on users: tracker_vehicle_id — text, optional.

2. rep_vehicle_positions — the trail. Pruned at 35 days.

field type notes
rep relation → users nullable — a vehicle may not map to a rep yet
vehicle_no text registration
coordinates geoPoint one geoPoint, not a lat/lon split
fixed_at date from GPSActualTime; dedupe key with vehicle_no
speed_kmh number
ignition bool nullable — -- is "no wire", not "off"

Index (vehicle_no, fixed_at) — both the dedupe lookup and the trail query.

3. rep_stops — derived from the trail nightly. Kept indefinitely, so pruning the raw trail loses nothing that matters.

field type
rep relation → users
vehicle_no text
started_at, ended_at date
duration_minutes number
coordinates geoPoint
fix_count number
ignition_off bool
longest_gap_minutes number

4. rep_visits — one row per customer seen.

field type notes
rep relation → users
customer relation → customers
visited_at date
coordinates geoPoint where the rep was
position_source select tracker, manual
fix_age_seconds number how stale the fix was
notes text
pinned_customer bool true if this visit set the customer's coordinates

The poller

Build first — history can't be backfilled.

  • POST /api/v1/tracker/poll, guarded by assertAgentAuth.
  • n8n Schedule Trigger, cron */5 6-18 * * 1-5. The working-hours window lives in the cron expression, not in app settings — and disabling the workflow is the off switch.
  • One getTokenBaseLiveData call per cycle covers the whole fleet: 288 calls/day regardless of how many vehicles.
  • Store distinct fixes. Key on (vehicle_no, fixed_at) and skip unchanged ones. Parked trackers stop emitting new fixes, so a naive version writes the same row 20 times an hour. Deduping compresses a parked period to two rows and the dwell falls out of the gap.
  • Nightly: derive rep_stops from yesterday's trail, then delete breadcrumbs older than 35 days.

Volume: ~5 reps × ~120 fixes/day × 35 days ≈ 21k rows steady state. driver_locations already holds 54,132.

Honest limits, documented in the code:

  • Polling samples; it doesn't replay. Good for "how long at this shaft", not enough to reconstruct a route.
  • Duration is first-fix-to-last-fix, so it slightly understates dwell.
  • A tracker losing signal underground makes a gap that looks like a long stop. longest_gap_minutes exposes that so a report can flag it instead of asserting a confident three-hour visit.

The rep's flow

  1. Open /rep (already logged in).
  2. Tap Check in.
  3. Server calls FleetAI for that rep's vehicle → position + fix age.
  4. Screen shows the position, how old the fix is, and customers within 2 km — the rep's own first. A stale fix is a warning, not a blocker. A "widen search" button goes to 5 km.
  5. Nobody in range? Search by name. Picking a customer offers "Save this location for them", pre-ticked when they have no coordinates.
  6. Tick who they saw, optional note, Save.
  7. Server writes N rep_visits rows, stamps customers.coordinates for any newly-pinned customer, updates customers.lastvisiteddate.

No signal? They check in later — from the car, the gate, or that evening. The trail already knows where they were, so nothing is lost.


Files

Service layer (per AGENTS.md — never pb-helpers.ts):

  • src/lib/server/tracker-service.ts ✅ built
  • src/lib/server/visits-service.ts — getCheckInContext(repId), recordVisits(...), listVisits(...), setCustomerCoordinates(...)
  • src/lib/server/vehicle-trail-service.ts — pollFleet(), rollUpStops(date), pruneOldPositions()

API routes (thin):

  • POST /api/v1/tracker/poll — agent-auth, called by n8n
  • GET|POST /api/v1/visits/check-in
  • GET /api/v1/visits
  • PATCH /api/v1/customers/[id]/coordinates — the pin tool

Rep PWA (src/app/(rep)/rep/): layout.tsx, page.tsx, check-in-view.tsx, plus public/manifest-rep.json and public/sw-rep.js copied from the /m pair, and one pickSwPath() branch.

Office (src/app/(app)/): visits/page.tsx (filter by rep + date, Leaflet map), visits/pin-tool.tsx, and a visit-history block added to the existing coordinates card on customers/[id]/page.tsx.

Sidebar: one entry, { href: "/visits", label: "Visits" }, in managementItems after Customers. /rep is deliberately not in the sidebar — matching /m and /counter, which aren't either.


Build sequence

  1. Throwaway API probe. Settle the three assumptions above.
  2. Schema. You add the three collections + one field in PB admin.
  3. Poller. vehicle-trail-service.ts + poll route + n8n schedule + nightly rollup/prune. Ships alone and starts collecting immediately.
  4. Office: pin tool + /visits. Seed the main shafts and read the log before any phone exists.
  5. Rep PWA. Route group, manifest, SW, check-in screen.

Progress

  • ✅ src/lib/visit-matching.ts — rankNearbyCustomers (13 tests). isRealGeoPoint added to geo.ts, de-duplicating the Null-Island guard inlined in both customers/[id] and companies/[id].
  • ✅ src/lib/tracker-parsing.ts — FleetAI decoding (30 tests).
  • ✅ src/lib/server/tracker-service.ts — FleetAiAdapter + FakeTrackerAdapter, including the one-call getFleetPositions (32 tests). The fake is auto-selected without credentials, so everything downstream builds and tests with no vendor access.
  • ✅ src/lib/visit-stops.ts — detectStops (17 tests). Anchor-based clustering, jitter tolerance, ignition corroboration, longestGapMinutes as a confidence signal.
  • ⏳ Everything else, blocked on the PB admin work.

102 tests passing; tsc --noEmit clean on all new files. The suite's 9 pre-existing failing files were verified identical on a stashed tree.

Environment variables

FLEETAI_USERNAME=          # Company or Sub-Account level login
FLEETAI_PASSWORD=
FLEETAI_COMPANY_NAME=      # exact platform spelling; required by the poller
FLEETAI_BASE_URL=          # optional, defaults to https://fleetai.netstar.co.za
FLEETAI_PROJECT_ID=        # optional, defaults to 37
FLEETAI_TZ_OFFSET_MINUTES= # optional, defaults to 120 (SAST)
TRACKER_ADAPTER=fake       # optional, forces the fake adapter

Verification

  • Unit (npx vitest run): already covered for matching, parsing, the adapter and stop detection. Add: poller dedupe (same fix twice → one row), rollup produces the expected stops from a fixture trail, prune deletes only rows past retention.
  • Poller, live: hit POST /api/v1/tracker/poll twice a minute apart on :4001; the second call must add zero rows if no new fix arrived. That single assertion is the whole dedupe design.
  • Data: re-run the coordinate audit — customers with real coordinates is 0 today, so any non-zero count after pinning is directly attributable.
  • Browser: http://localhost:4001/rep at mobile viewport, check in against the fake tracker, tick a customer, confirm the row lands and the pin is written.
  • PWA: /manifest-rep.json loads, sw-rep.js registers, installs to home screen.

What was cut, and why

Removed in the simplification pass — recorded so we don't re-add them out of habit:

  • The offline queue (IndexedDB, /visits/sync, reconciliation, phone_coordinates, synced_at, two position_source values). The breadcrumb trail made it redundant: a rep with no signal checks in later and we already know where they were. This was the single largest piece of complexity in the plan and it earned nothing.
  • The rep_checkins collection. Its justification was "a check-in where the rep saw nobody is still a fact" — but that fact now lives in rep_stops. Merging the position onto rep_visits costs two duplicated floats and removes a join from every query.
  • The rep_visit_settings collection, all ten fields. The poll window belongs in the n8n cron expression; the rest are constants that cost one line to change. The 2 km radius is better served by a "widen search" button on the phone than by a setting the rep can't reach while standing at a gate.
  • rep_stops.nearest_customer and .matched_visit. Schema for a report that doesn't exist yet. Both are computable at read time.
  • Five rep_vehicle_positions fields (address, odometer_km, status, polled_at, imei). Nothing in round one reads them.

Still out of scope

  • Time-spent-per-site report. The data will be there from day one, which is the point; the report itself is round two.
  • Rep portal (quotes/orders on the phone). /dashboard2 already covers rep-scoped metrics on desktop.
  • A sites/shafts entity, or FleetAI geofences. Radius first. Geofences (createGeofence / getGeofenceAlerts gives exact entry/exit) are the upgrade path if polled stops prove too coarse — ask Netstar the geofence count limit before committing to that.
  • Backfilling all 616 customers. Main shafts get pinned; the tail fills in as reps visit.