diff --git a/docs/plans/2026-05-20-six-features.md b/docs/plans/2026-05-20-six-features.md new file mode 100644 index 0000000..463aa33 --- /dev/null +++ b/docs/plans/2026-05-20-six-features.md @@ -0,0 +1,92 @@ +# Canteen Asset Tracker — Feature Batch Implementation Plan + +**Date:** 2026-05-20 +**Goal:** Six UX improvements for field technicians + +--- + +## Feature 1: Scan existing asset → auto check-in +**Current:** Scanning a known asset shows it with a manual "Check In" button +**Target:** Scanning a known asset immediately checks in with GPS, no extra tap + +**Files:** +- `static/index.html` — `handleBarcode()` function (line ~2638) + +**Change:** After `showScannedAsset(asset)`, call `autoCheckin(asset.id)` + `submitCheckin()`-equivalent logic. Skip showing the manual checkin card. + +--- + +## Feature 2: Auto-generate address from GPS reverse geocode +**Current:** Address fields are always empty; user must type them manually +**Target:** When creating an asset (all 3 modes), reverse-geocode GPS to populate address fields + +**Files:** +- `static/index.html` — `createScannedAsset()`, `createOcrAsset()`, `submitManualAsset()` +- `server.py` — new endpoint `GET /api/geocode?lat=&lng=` using OpenStreetMap Nominatim + +**Approach:** Add a helper `reverseGeocode(lat, lng)` that calls Nominatim API, returns address parts. Call it in all three creation paths before submitting. Populate `address`, `building_name`, `building_number`, `floor` fields from the response. + +--- + +## Feature 3: Larger camera viewport +**Current:** Camera area uses `aspect-ratio: 16/12` — decent but could be taller +**Target:** Bigger camera preview for easier barcode/OCR scanning + +**Files:** +- `static/index.html` — `.camera-area` CSS (~line 437) + +**Change:** Increase aspect ratio to `4/3` or make it fill more vertical space. Reduce margins. Consider max-height removal. + +--- + +## Feature 4: Building # → trailer number sync +**Current:** `building_number` and `trailer_number` are separate fields +**Target:** When building_number is entered, auto-populate trailer_number with same value + +**Files:** +- `static/index.html` — manual form event listeners, auto-checkin logic + +**Change:** Add an `oninput` handler on the building number field that copies the value to trailer number. Also handle the OCR/barcode creation paths if they ever populate building number. + +--- + +## Feature 5: Parking location from current GPS +**Current:** `parking_location` is a free-text field +**Target:** Add a "📍 Use Current Location" button next to parking location that fills in GPS coords + +**Files:** +- `static/index.html` — manual form HTML + JS + +**Change:** Add a button next to `manParkingLocation` that sets its value to `${AppState.gpsLat}, ${AppState.gpsLng}` when tapped. Also add this to the scanned/OCR create flows if those forms have parking. + +--- + +## Feature 6: Navigation tool +**Current:** Map shows asset pins with a popup "Directions" link to Google Maps +**Target:** A dedicated navigation tool in the app that shows route from current location to the selected asset + +**Files:** +- `static/index.html` — new tab/panel or enhancement to map/asset detail + +**Approach:** Add a "🧭 Navigate" button in asset detail view and map popups. Opens a full-screen map with: +- Current GPS position marker (blue dot) +- Asset location marker +- Route line between them (straight line using Leaflet polyline, since Leaflet routing needs external service) +- Distance readout +- "Open in Google Maps" fallback link + +**Simpler approach:** Enhance existing popup "Directions" link to include the user's current GPS as the origin (`&origin=lat,lng`). Also add a "Navigate" tab that shows current location → selected asset with distance. + +--- + +## Execution Order +All 6 features are independent (touch different parts of frontend) except F6 may touch map code that F3 doesn't touch. Safe to parallelize. + +- F1, F2, F3, F4, F5, F6 can all be done in parallel +- F2 depends on backend endpoint first + +**Plan:** +1. Write backend endpoint for F2 (reverse geocode) +2. Implement all 6 frontend changes +3. Test end-to-end +4. Commit, push, update docs/wiki/issues diff --git a/server.py b/server.py index 3199f6a..251d822 100644 --- a/server.py +++ b/server.py @@ -998,6 +998,72 @@ def get_asset(asset_id: int): return result +# ─── Navigation endpoint ──────────────────────────────────────────────────── + +@app.get("/api/assets/{asset_id}/navigation") +def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(...)): + """Return distance, bearing, and coordinates for navigation from GPS to asset.""" + import math + + conn = get_db() + row = conn.execute( + "SELECT id, name, latitude, longitude, machine_id, address FROM assets WHERE id = ?", + (asset_id,), + ).fetchone() + conn.close() + + if row is None: + raise HTTPException(status_code=404, detail="Asset not found") + + if row["latitude"] is None or row["longitude"] is None: + raise HTTPException( + status_code=400, + detail="Asset has no GPS coordinates set", + ) + + dest_lat, dest_lng = row["latitude"], row["longitude"] + + # Haversine distance + R = 6371000 # Earth radius in meters + phi1 = math.radians(lat) + phi2 = math.radians(dest_lat) + dphi = math.radians(dest_lat - lat) + dlambda = math.radians(dest_lng - lng) + + a_val = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + c_val = 2 * math.atan2(math.sqrt(a_val), math.sqrt(1 - a_val)) + distance_m = R * c_val + + # Bearing + y = math.sin(dlambda) * math.cos(phi2) + x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlambda) + bearing = (math.degrees(math.atan2(y, x)) + 360) % 360 + + # Cardinal direction + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + idx = round(bearing / 45) % 8 + cardinal = dirs[idx] + + return { + "asset_id": asset_id, + "asset_name": row["name"], + "asset_lat": dest_lat, + "asset_lng": dest_lng, + "asset_address": row["address"] or None, + "origin_lat": lat, + "origin_lng": lng, + "distance_meters": round(distance_m, 1), + "distance_km": round(distance_m / 1000, 2), + "bearing": round(bearing, 1), + "cardinal": cardinal, + "google_maps_url": ( + f"https://www.google.com/maps/dir/?api=1" + f"&origin={lat},{lng}" + f"&destination={dest_lat},{dest_lng}" + ), + } + + # ─── Task 6: PUT / DELETE /api/assets/{id} ───────────────────────────────── @@ -2595,6 +2661,64 @@ async def ocr_sticker(file: UploadFile = File(...)): } +# ─── Reverse Geocode (Nominatim) ──────────────────────────────────────────── + +@app.get("/api/geocode") +def geocode(lat: float = Query(...), lng: float = Query(...)): + """Reverse geocode GPS coordinates to address using OpenStreetMap Nominatim.""" + import urllib.request + import urllib.error + try: + url = ( + f"https://nominatim.openstreetmap.org/reverse" + f"?format=json&lat={lat}&lon={lng}&zoom=18&addressdetails=1" + ) + # Set a proper User-Agent as required by Nominatim ToS + req = urllib.request.Request(url, headers={"User-Agent": "CanteenAssetTracker/2.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data = _json.loads(resp.read()) + except (urllib.error.URLError, _json.JSONDecodeError, OSError) as e: + raise HTTPException(status_code=502, detail=f"Geocoding failed: {str(e)}") + + addr = data.get("address", {}) + display_name = data.get("display_name", "") + + # Map Nominatim address parts to our schema + building = addr.get("building", "") or addr.get("house_number", "") + building_name = addr.get("building_name", "") or addr.get("amenity", "") + street = addr.get("road", "") or addr.get("pedestrian", "") or addr.get("path", "") + house_number = addr.get("house_number", "") + city = addr.get("city", "") or addr.get("town", "") or addr.get("village", "") + state = addr.get("state", "") + postcode = addr.get("postcode", "") + + # Build a formatted address string + parts = [] + if house_number and street: + parts.append(f"{house_number} {street}") + elif street: + parts.append(street) + if city: + parts.append(city) + if state: + parts.append(state) + if postcode: + parts.append(postcode) + formatted = ", ".join(parts) if parts else display_name + + return { + "display_name": display_name, + "formatted": formatted, + "building_number": house_number or building, + "building_name": building_name, + "street": street, + "city": city, + "state": state, + "postcode": postcode, + "address": formatted, # mapped to our asset.address field + } + + # ─── Static Files (mounted last to not shadow routes) ────────────────────── app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") diff --git a/static/index.html b/static/index.html index 926f3a8..29f2579 100644 --- a/static/index.html +++ b/static/index.html @@ -435,8 +435,8 @@ ═══════════════════════════════════════════════════════════════════════ */ .camera-area { position: relative; background: #0a0b0f; border-radius: var(--radius-sm); - aspect-ratio: 16/14; display: flex; align-items: center; justify-content: center; - overflow: hidden; margin-bottom: 4px; + aspect-ratio: 3/4; display: flex; align-items: center; justify-content: center; + overflow: hidden; margin: 0 -12px 8px -12px; max-height: none; } .camera-area video { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; } .camera-placeholder { text-align: center; z-index: 1; cursor: pointer; } @@ -1235,7 +1235,7 @@