feat(clock): enhance vehicle scan API with GPS support

Updated the vehicle scan API to include optional GPS coordinates (latitude, longitude) and location accuracy. The API now validates these fields, ensuring both latitude and longitude are provided together and within valid ranges. Enhanced error handling for invalid inputs and updated the API reference documentation accordingly. Additionally, modified the FleetScanner component to capture GPS data and reflect its status in the UI.
This commit is contained in:
DEls 2026-09-22 19:59:44 +02:00
parent 3077223d38
commit a589b14ac9
7 changed files with 496 additions and 10 deletions

View file

@ -550,16 +550,31 @@ dash reading) and stamps a zero-litre row. Capturing fuel alongside
is left to the spreadsheet import — the Fleet tab is for the
end-of-shift reading, not for the pump sheet.
GPS coordinates are optional: the MEGA Clock Fleet scanner sends them
(kiosk phone), the spreadsheet import path doesn't have a GPS context
and leaves them null. A half-fix (lat without lng, or vice versa) is
rejected with 400 rather than written as a half-populated row.
**Body:**
```ts
{ qr_code_id: string, odo_reading: number }
{
qr_code_id: string,
odo_reading: number,
latitude?: number, // decimal degrees, [-90, 90]
longitude?: number, // decimal degrees, [-180, 180]
location_accuracy_m?: number, // metres, ≥ 0 (diagnostic, optional)
}
```
**Responses:**
- `200 { vehicleId, odoReading, dateLogged, logId }`
- `200 { vehicleId, odoReading, dateLogged, logId, hasFix }` — `hasFix` is true when a lat/lng pair was persisted
- `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` /
`"odo_reading is required"` / `"odo_reading must be greater than zero"`
/ `{ error: "<pocketBase field-level error>" }`
`"odo_reading is required"` / `"odo_reading must be greater than zero"` /
`"latitude and longitude must be supplied together"` /
`"latitude must be a finite number in [-90, 90]"` /
`"longitude must be a finite number in [-180, 180]"` /
`"location_accuracy_m must be a non-negative finite number"` /
`{ error: "<pocketBase field-level error>" }`
- `404 { error: "Unknown vehicle QR" }`
---

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
One-shot PocketBase migration applied via:
PB_URL=https://pb.mega.infra.apphub.co.za \\
PB_ADMIN_EMAIL=admin@mega.co.za \\
PB_ADMIN_PASSWORD=... \\
python3 scripts/migrations/2026-09-22-add-fuel-log-coordinates.py
Adds `latitude`, `longitude`, `location_accuracy_m` to FUEL_LOGS so the
MEGA Clock Fleet scanner can record the GPS fix at the moment of scan.
The spreadsheet import path doesn't have a GPS context, so
desktop-imported rows stay null — same posture as `latest_consumption`,
which this migration sits next to in the collection's field list.
Range validation (lat ∈ [-90, 90], lng ∈ [-180, 180], accuracy ≥ 0)
is the route's responsibility, not PB's — the field stays permissive
so a future capture device (a hand-held GPS, a survey tool) can write
whatever it reports and a separate cleanup job decides which fixes
are usable.
Idempotent: re-running is safe; existing fields are skipped.
"""
import json
import os
import sys
import urllib.error
import urllib.request
COLLECTION = "fuel_logs"
NEW_FIELDS = [
{
"name": "latitude",
"type": "number",
"required": False,
# No min/max / noDecimal — mirrors `litres` and `odo_reading`.
},
{
"name": "longitude",
"type": "number",
"required": False,
},
{
"name": "location_accuracy_m",
"type": "number",
"required": False,
},
]
def admin_auth(base_url: str) -> str:
email = os.environ["PB_ADMIN_EMAIL"]
password = os.environ["PB_ADMIN_PASSWORD"]
body = json.dumps({"identity": email, "password": password}).encode()
req = urllib.request.Request(
f"{base_url}/api/collections/_superusers/auth-with-password",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())["token"]
def get_collection(base_url: str, token: str, name: str):
req = urllib.request.Request(
f"{base_url}/api/collections/{name}",
headers={"Authorization": token},
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def patch_collection(base_url: str, token: str, name: str, body: dict):
req = urllib.request.Request(
f"{base_url}/api/collections/{name}",
data=json.dumps(body).encode(),
headers={
"Authorization": token,
"Content-Type": "application/json",
},
method="PATCH",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body_txt = e.read().decode() if e.fp else ""
raise RuntimeError(f"PATCH {name} failed: {e.code} {body_txt}") from e
def main() -> int:
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
token = admin_auth(base_url)
coll = get_collection(base_url, token, COLLECTION)
if not coll:
print(f"ERROR: collection {COLLECTION!r} not found on {base_url}")
return 1
existing_fields = coll.get("fields", [])
existing_field_names = {f["name"] for f in existing_fields}
new_fields = [f for f in NEW_FIELDS if f["name"] not in existing_field_names]
if new_fields:
merged_fields = existing_fields + new_fields
print(f"Adding {len(new_fields)} new field(s) to {COLLECTION}: "
f"{[f['name'] for f in new_fields]}")
patch_collection(base_url, token, COLLECTION, {"fields": merged_fields})
else:
print(f"No-op: all NEW_FIELDS already present on {COLLECTION}.")
final = get_collection(base_url, token, COLLECTION)
final_field_names = sorted(f["name"] for f in final.get("fields", []))
print(f"\nFinal field list on {COLLECTION}: {final_field_names}")
print("\nMigration complete.")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyError as e:
print(f"ERROR: missing environment variable {e}. "
"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.", file=sys.stderr)
sys.exit(2)

View file

@ -323,6 +323,13 @@ DEFS["fuel_logs"] = {
field("litres", "number", required=False),
field("odo_reading", "number", required=False),
field("latest_consumption", "number", required=False),
# GPS fix captured at scan time on the kiosk phone (MEGA Clock
# Fleet tab). Optional — the spreadsheet import path has no GPS
# context, so desktop-imported rows stay null. Range validation
# is the route's job; PB stays permissive.
field("latitude", "number", required=False),
field("longitude", "number", required=False),
field("location_accuracy_m", "number", required=False),
field("fuel_sheet", "relation", required=False,
collectionId="__FUEL_SHEETS_ID__", cascadeDelete=False, maxSelect=1),
],

View file

@ -108,6 +108,7 @@ describe("POST /api/clock/vehicle-scan", () => {
odoReading: 12345.6,
dateLogged: "2026-09-22T00:00:00.000Z",
logId: "log00001",
hasFix: false,
});
expect(fuelLogsCreate).toHaveBeenCalledWith({
site: VEHICLE.site,
@ -115,6 +116,9 @@ describe("POST /api/clock/vehicle-scan", () => {
date_logged: "2026-09-22T00:00:00.000Z",
litres: 0,
odo_reading: 12345.6,
latitude: null,
longitude: null,
location_accuracy_m: null,
});
});
@ -144,6 +148,116 @@ describe("POST /api/clock/vehicle-scan", () => {
);
});
it("persists a lat/lng/accuracy fix when the client supplies one", async () => {
vehiclesGetFirst.mockResolvedValueOnce(VEHICLE);
fuelLogsCreate.mockResolvedValueOnce({ id: "logFix" });
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1234,
latitude: -26.2041,
longitude: 28.0473,
location_accuracy_m: 6.5,
});
const json = await res.json();
expect(res.status).toBe(200);
expect(json.hasFix).toBe(true);
expect(fuelLogsCreate).toHaveBeenCalledWith(
expect.objectContaining({
latitude: -26.2041,
longitude: 28.0473,
location_accuracy_m: 6.5,
}),
);
});
it("returns 400 when only latitude is supplied (half a fix)", async () => {
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
latitude: -26.2041,
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "latitude and longitude must be supplied together",
});
expect(fuelLogsCreate).not.toHaveBeenCalled();
});
it("returns 400 when only longitude is supplied (half a fix)", async () => {
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
longitude: 28.0473,
});
expect(res.status).toBe(400);
expect(fuelLogsCreate).not.toHaveBeenCalled();
});
it("rejects latitudes outside [-90, 90]", async () => {
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
latitude: 95,
longitude: 0,
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "latitude must be a finite number in [-90, 90]",
});
expect(fuelLogsCreate).not.toHaveBeenCalled();
});
it("rejects longitudes outside [-180, 180]", async () => {
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
latitude: 0,
longitude: -181,
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "longitude must be a finite number in [-180, 180]",
});
expect(fuelLogsCreate).not.toHaveBeenCalled();
});
it("rejects negative accuracy", async () => {
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
latitude: -26.2,
longitude: 28.0,
location_accuracy_m: -1,
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
error: "location_accuracy_m must be a non-negative finite number",
});
expect(fuelLogsCreate).not.toHaveBeenCalled();
});
it("accepts a fix with no accuracy (accuracy is diagnostic metadata)", async () => {
vehiclesGetFirst.mockResolvedValueOnce(VEHICLE);
fuelLogsCreate.mockResolvedValueOnce({ id: "logNoAcc" });
const res = await post({
qr_code_id: "QR-PLANT-001",
odo_reading: 1000,
latitude: -26.2,
longitude: 28.0,
});
expect(res.status).toBe(200);
expect(fuelLogsCreate).toHaveBeenCalledWith(
expect.objectContaining({
latitude: -26.2,
longitude: 28.0,
location_accuracy_m: null,
}),
);
});
it("surfaces PocketBase validation errors with a 400 + their message", async () => {
vehiclesGetFirst.mockResolvedValueOnce(VEHICLE);
fuelLogsCreate.mockRejectedValueOnce(

View file

@ -52,10 +52,21 @@ function formatPbError(err: unknown): string {
* this directory.
*
* Body:
* { qr_code_id: string, odo_reading: number }
* {
* qr_code_id: string,
* odo_reading: number,
* latitude?: number, // decimal degrees, [-90, 90]
* longitude?: number, // decimal degrees, [-180, 180]
* location_accuracy_m?: number, // metres, ≥ 0
* }
* lat/lng must be supplied together (a half-fix is rejected with 400
* rather than written as a half-populated row). accuracy may be
* omitted on its own — it's diagnostic metadata.
*
* Response (200):
* { vehicleId, odoReading, dateLogged, logId }
* { vehicleId, odoReading, dateLogged, logId, hasFix }
* `hasFix` is true when a lat/lng pair was persisted, false when the
* caller didn't supply one (desktop, denied permission, no signal).
*/
export async function POST(request: Request) {
let body: Record<string, unknown>;
@ -82,6 +93,42 @@ export async function POST(request: Request) {
);
}
// GPS fix is optional. The Fleet scanner on the kiosk phone sends it;
// any other caller is free to omit it. We validate the geometry
// (lat ∈ [-90, 90], lng ∈ [-180, 180], accuracy ≥ 0) but stay
// permissive on the rest — partial fixes (lat without lng, or
// accuracy without coords) are a sign of a buggy client, so we 400
// on those rather than writing a half-populated row.
const lat = typeof body.latitude === "number" ? body.latitude : null;
const lng = typeof body.longitude === "number" ? body.longitude : null;
const accuracy =
typeof body.location_accuracy_m === "number" ? body.location_accuracy_m : null;
if ((lat === null) !== (lng === null)) {
return NextResponse.json(
{ error: "latitude and longitude must be supplied together" },
{ status: 400 },
);
}
if (lat !== null && (!Number.isFinite(lat) || lat < -90 || lat > 90)) {
return NextResponse.json(
{ error: "latitude must be a finite number in [-90, 90]" },
{ status: 400 },
);
}
if (lng !== null && (!Number.isFinite(lng) || lng < -180 || lng > 180)) {
return NextResponse.json(
{ error: "longitude must be a finite number in [-180, 180]" },
{ status: 400 },
);
}
if (accuracy !== null && (!Number.isFinite(accuracy) || accuracy < 0)) {
return NextResponse.json(
{ error: "location_accuracy_m must be a non-negative finite number" },
{ status: 400 },
);
}
const pb = await pbAdmin();
let vehicle: VehicleRecord;
@ -105,12 +152,20 @@ export async function POST(request: Request) {
date_logged: dateLogged,
litres: 0,
odo_reading: odoReading,
// PB coerces `null` to a missing field on write for optional
// number columns, so passing the validated values verbatim
// keeps desktop-imported rows null while scanned rows get the
// fix. `undefined` would do the same thing; null is explicit.
latitude: lat,
longitude: lng,
location_accuracy_m: accuracy,
});
return NextResponse.json({
vehicleId: vehicle.id,
odoReading,
dateLogged,
logId: created.id,
hasFix: lat !== null,
});
} catch (err) {
return NextResponse.json(

View file

@ -2,7 +2,7 @@
import * as React from "react";
import jsQR from "jsqr";
import { Keyboard, Loader2 } from "lucide-react";
import { Keyboard, Loader2, MapPin, MapPinOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -68,6 +68,14 @@ interface VehiclePayload {
costPerHour: number;
}
/** GPS fix captured from `navigator.geolocation`. Accuracy is the
* `Position.coords.accuracy` in metres at the moment of capture. */
interface GpsFix {
latitude: number;
longitude: number;
accuracy: number;
}
type Phase =
| { kind: "scanning" }
| { kind: "loading"; qrCodeId: string }
@ -88,6 +96,25 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
const phaseRef = React.useRef<Phase>({ kind: "scanning" });
const [phase, setPhaseRaw] = React.useState<Phase>({ kind: "scanning" });
const [cameraError, setCameraError] = React.useState<string | null>(null);
// GPS fix state — captured once when the form opens, displayed in
// the popup so the operator can see whether a fix is on the row.
// `pending` means we asked but the browser hasn't answered yet;
// `ok` means a fix is in `gpsFix`; `unavailable` covers "denied",
// "no signal", and "no geolocation API" — all three collapse into
// the same UI ("📍 No location"), because the action from the
// operator's perspective is identical (submit without coordinates).
const [gpsStatus, setGpsStatus] = React.useState<"pending" | "ok" | "unavailable">(
"unavailable",
);
const [gpsFix, setGpsFix] = React.useState<GpsFix | null>(null);
// Snapshot for the submit — the effect that watches submit could
// otherwise race a slow GPS resolution that lands mid-POST and
// silently swap what's written. Refs capture the value at the
// moment submit fires; the state is for UI.
const gpsFixRef = React.useRef<GpsFix | null>(null);
React.useEffect(() => {
gpsFixRef.current = gpsFix;
}, [gpsFix]);
// Manual-entry dialog is independent of `phase` — it sits over the
// scanning view without flipping the state machine, so the camera
// keeps running behind it. The ref is read by handleDecoded to make
@ -103,6 +130,42 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
setPhaseRaw(next);
}, []);
// --- GPS capture ---------------------------------------------------
// Wrapped so each lookup call gets a fresh attempt (cleared at the
// start of every new form, replaced if the operator cancels and
// scans again). 8-second cap keeps a denied/quiet GPS from holding
// the form open forever — the operator can still submit without a
// fix, and a stalled `pending` chip would just be noise.
const captureGps = React.useCallback(() => {
setGpsStatus("pending");
setGpsFix(null);
if (typeof navigator === "undefined" || !navigator.geolocation) {
setGpsStatus("unavailable");
return;
}
const timer = setTimeout(() => {
// If the browser still hasn't answered, treat it as unavailable
// rather than leaving the chip on "pending" indefinitely.
setGpsStatus((current) => (current === "pending" ? "unavailable" : current));
}, 8000);
navigator.geolocation.getCurrentPosition(
(pos) => {
clearTimeout(timer);
setGpsFix({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
});
setGpsStatus("ok");
},
() => {
clearTimeout(timer);
setGpsStatus("unavailable");
},
{ enableHighAccuracy: true, timeout: 7000, maximumAge: 30_000 },
);
}, []);
// --- Lookup + submit -----------------------------------------------
// Wrapped in useCallback so the camera effect can call them with a
// stable identity and not tear down the camera every render.
@ -137,6 +200,11 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
vehicle: ok.vehicle,
previousReading: ok.previousReading,
});
// Kick off the GPS capture the moment the form opens. The
// operator will spend several seconds entering the reading;
// that overlap is exactly what we want — by the time they
// hit Save the fix is either resolved or already given up.
captureGps();
// busyRef stays true until Cancel or Submit — see camera
// effect's `handleDecoded` for the gate.
} catch {
@ -149,7 +217,7 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
}, BANNER_MS);
}
},
[setPhase],
[captureGps, setPhase],
);
// --- Shared entry point ---------------------------------------------
@ -179,11 +247,27 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
previousReading,
reading: String(reading),
});
// Snapshot the current fix so a slow GPS resolution that lands
// mid-submit can't change what's written. If the operator
// hasn't seen the chip turn green, the row goes in without
// coordinates — that's the safer default (a wrong reading from
// an old fix would be worse than no reading at all).
const fix = gpsFixRef.current;
try {
const res = await fetch("/api/clock/vehicle-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ qr_code_id: qrCodeId, odo_reading: reading }),
body: JSON.stringify({
qr_code_id: qrCodeId,
odo_reading: reading,
...(fix
? {
latitude: fix.latitude,
longitude: fix.longitude,
location_accuracy_m: fix.accuracy,
}
: {}),
}),
});
const body = await res.json();
if (!res.ok) {
@ -368,6 +452,8 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
<DialogContent className="max-w-md border-border bg-card p-0 sm:rounded-2xl">
<DialogBody
phase={phase}
gpsStatus={gpsStatus}
gpsFix={gpsFix}
onSubmit={(reading) => {
if (phase.kind !== "form" && phase.kind !== "submitError") return;
const { qrCodeId, vehicle, previousReading } = phase;
@ -402,9 +488,13 @@ function unitSuffix(unitType: "fuel" | "plant"): string {
function DialogBody({
phase,
gpsStatus,
gpsFix,
onSubmit,
}: {
phase: Phase;
gpsStatus: "pending" | "ok" | "unavailable";
gpsFix: GpsFix | null;
onSubmit: (reading: number) => void;
}) {
if (phase.kind === "loading") {
@ -445,6 +535,8 @@ function DialogBody({
qrCodeId={phase.qrCodeId}
vehicle={phase.vehicle}
previousReading={phase.previousReading}
gpsStatus={gpsStatus}
gpsFix={gpsFix}
submitError={phase.kind === "submitError" ? phase.message : null}
onSubmit={onSubmit}
/>
@ -458,12 +550,16 @@ function FormBody({
qrCodeId,
vehicle,
previousReading,
gpsStatus,
gpsFix,
submitError,
onSubmit,
}: {
qrCodeId: string;
vehicle: VehiclePayload;
previousReading: number | null;
gpsStatus: "pending" | "ok" | "unavailable";
gpsFix: GpsFix | null;
submitError: string | null;
onSubmit: (reading: number) => void;
}) {
@ -503,8 +599,9 @@ function FormBody({
<DialogTitle className="text-xl leading-tight">{vehicle.description}</DialogTitle>
</DialogHeader>
<div className="px-6 pb-2">
<div className="space-y-2 px-6 pb-2">
<PreviousReadingStrip previousReading={previousReading} suffix={suffix} />
<GpsChip status={gpsStatus} fix={gpsFix} />
</div>
<form
@ -595,6 +692,59 @@ function PreviousReadingStrip({
);
}
/**
* "📍 Location captured · ±6 m" / "📍 No location" — a small status
* chip under the previous-reading strip. Tells the operator what
* will end up on the row before they hit Save.
*
* Three states:
* pending capture is in flight — neutral, doesn't claim more
* ok a fix is captured; show coords + accuracy
* unavailable denied, no signal, or no geolocation API — the row
* will be saved without coordinates, which is the
* safe default for a denied permission
*/
function GpsChip({
status,
fix,
}: {
status: "pending" | "ok" | "unavailable";
fix: GpsFix | null;
}) {
if (status === "pending") {
return (
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
<span className="font-semibold uppercase tracking-wide">Location</span>
<span>looking for a fix…</span>
</div>
);
}
if (status === "ok" && fix) {
return (
<div className="flex items-center justify-between rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 ring-1 ring-emerald-500/30 dark:text-emerald-300">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wide">
<MapPin className="h-3.5 w-3.5" aria-hidden />
Location captured
</span>
<span className="font-mono">
{fix.latitude.toFixed(4)}, {fix.longitude.toFixed(4)}
<span className="ml-1.5 text-muted-foreground">±{Math.round(fix.accuracy)} m</span>
</span>
</div>
);
}
return (
<div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wide">
<MapPinOff className="h-3.5 w-3.5" aria-hidden />
No location
</span>
<span>reading will save without GPS</span>
</div>
);
}
/** Viewfinder chrome, themed for fleet (orange = the brand colour
* throughout the dashboard). Same vignette trick as PersonScanner:
* a huge box-shadow on a transparent viewfinder paints the dim

View file

@ -253,6 +253,18 @@ export interface FuelLogRecord extends RecordModel {
* fabricating a value. Added in the 2026-08-24 migration.
*/
latest_consumption?: number;
/**
* GPS fix captured at scan time on the kiosk phone (MEGA Clock Fleet
* tab). The spreadsheet import path doesn't have a GPS context, so
* desktop-imported rows will have all three fields null — exactly
* the same posture as `latest_consumption`. Decimal degrees; range
* validation is the route's responsibility, not PB's. Added in the
* 2026-09-22 migration.
*/
latitude?: number | null;
longitude?: number | null;
/** Reported `Position.coords.accuracy` in metres at scan time. */
location_accuracy_m?: number | null;
/** The uploaded pump sheet this row came from. Optional: pre-migration rows have none. */
fuel_sheet?: string;
expand?: {