diff --git a/docs/api-reference.md b/docs/api-reference.md index 17e1188..fbced9e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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: "" }` + `"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: "" }` - `404 { error: "Unknown vehicle QR" }` --- diff --git a/scripts/migrations/2026-09-22-add-fuel-log-coordinates.py b/scripts/migrations/2026-09-22-add-fuel-log-coordinates.py new file mode 100644 index 0000000..3405a99 --- /dev/null +++ b/scripts/migrations/2026-09-22-add-fuel-log-coordinates.py @@ -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) \ No newline at end of file diff --git a/scripts/pb-bootstrap-schema.py b/scripts/pb-bootstrap-schema.py index 04f9411..fa79437 100644 --- a/scripts/pb-bootstrap-schema.py +++ b/scripts/pb-bootstrap-schema.py @@ -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), ], diff --git a/src/app/api/clock/vehicle-scan/route.test.ts b/src/app/api/clock/vehicle-scan/route.test.ts index 18224f5..bd94776 100644 --- a/src/app/api/clock/vehicle-scan/route.test.ts +++ b/src/app/api/clock/vehicle-scan/route.test.ts @@ -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( diff --git a/src/app/api/clock/vehicle-scan/route.ts b/src/app/api/clock/vehicle-scan/route.ts index 5e6a6e9..952bf03 100644 --- a/src/app/api/clock/vehicle-scan/route.ts +++ b/src/app/api/clock/vehicle-scan/route.ts @@ -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; @@ -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( diff --git a/src/app/clock/fleet-scanner.tsx b/src/app/clock/fleet-scanner.tsx index 050e875..09dd8fb 100644 --- a/src/app/clock/fleet-scanner.tsx +++ b/src/app/clock/fleet-scanner.tsx @@ -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({ kind: "scanning" }); const [phase, setPhaseRaw] = React.useState({ kind: "scanning" }); const [cameraError, setCameraError] = React.useState(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(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(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 } = {}) { { 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({ {vehicle.description} -
+
+
+ + Location + looking for a fix… +
+ ); + } + if (status === "ok" && fix) { + return ( +
+ + + Location captured + + + {fix.latitude.toFixed(4)}, {fix.longitude.toFixed(4)} + ±{Math.round(fix.accuracy)} m + +
+ ); + } + return ( +
+ + + No location + + reading will save without GPS +
+ ); +} + /** 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 diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index cc8938d..fb2cf07 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -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?: {