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>
18 KiB
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
- PocketBase schema — blocked on the user. The three collections
and one field below are UI-only work in the PB admin. Per
AGENTS.mdthe API 500s on anything that doesn't exist yet. Nothing that writes data can be built until these exist. - FleetAI credentials — blocked on the user. Until then
getTrackerAdapter()auto-selectsFakeTrackerAdapter, 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, thepickSwPath()branch, and the check-in screen reading fromgetCheckInContextagainst 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
/repbranch. - customers-service.ts —
listCustomersbacks the phone's search box. - agent-auth.ts —
assertAgentAuth, BearerAGENT_API_KEY. The poll route uses it, like the existing cron workflows. - isRepUser — scopes
/visitswithout 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 itsgeoPointcolumn is all-zero whilelatitude/longitudecarry 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):
- Errors arrive as HTTP 200 with a failure envelope.
response.oktells you nothing. --means "unavailable", not null — and forIGN, notfalseeither. A bakkie with no ignition wire isn't "parked".- 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:
- 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_idis the wrong model and the whole check-in flow changes shape. - Does
getVehicleCurrentLocationreturnGPSActualTime, or onlytimestamp(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. - 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 byassertAgentAuth.- 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
getTokenBaseLiveDatacall 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_stopsfrom 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_minutesexposes that so a report can flag it instead of asserting a confident three-hour visit.
The rep's flow
- Open
/rep(already logged in). - Tap Check in.
- Server calls FleetAI for that rep's vehicle → position + fix age.
- 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.
- Nobody in range? Search by name. Picking a customer offers "Save this location for them", pre-ticked when they have no coordinates.
- Tick who they saw, optional note, Save.
- Server writes N
rep_visitsrows, stampscustomers.coordinatesfor any newly-pinned customer, updatescustomers.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✅ builtsrc/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 n8nGET|POST /api/v1/visits/check-inGET /api/v1/visitsPATCH /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
- Throwaway API probe. Settle the three assumptions above.
- Schema. You add the three collections + one field in PB admin.
- Poller.
vehicle-trail-service.ts+ poll route + n8n schedule + nightly rollup/prune. Ships alone and starts collecting immediately. - Office: pin tool +
/visits. Seed the main shafts and read the log before any phone exists. - Rep PWA. Route group, manifest, SW, check-in screen.
Progress
- ✅
src/lib/visit-matching.ts—rankNearbyCustomers(13 tests).isRealGeoPointadded togeo.ts, de-duplicating the Null-Island guard inlined in bothcustomers/[id]andcompanies/[id]. - ✅
src/lib/tracker-parsing.ts— FleetAI decoding (30 tests). - ✅
src/lib/server/tracker-service.ts—FleetAiAdapter+FakeTrackerAdapter, including the one-callgetFleetPositions(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,longestGapMinutesas 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/polltwice 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/repat mobile viewport, check in against the fake tracker, tick a customer, confirm the row lands and the pin is written. - PWA:
/manifest-rep.jsonloads,sw-rep.jsregisters, 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, twoposition_sourcevalues). 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_checkinscollection. Its justification was "a check-in where the rep saw nobody is still a fact" — but that fact now lives inrep_stops. Merging the position ontorep_visitscosts two duplicated floats and removes a join from every query. - The
rep_visit_settingscollection, 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_customerand.matched_visit. Schema for a report that doesn't exist yet. Both are computable at read time.- Five
rep_vehicle_positionsfields (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).
/dashboard2already covers rep-scoped metrics on desktop. - A sites/shafts entity, or FleetAI geofences. Radius first. Geofences
(
createGeofence/getGeofenceAlertsgives 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.