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 @@
- +
@@ -1246,7 +1246,10 @@
- +
+ + +
@@ -1362,6 +1365,7 @@
+
@@ -1582,7 +1586,7 @@
- +
@@ -2687,7 +2691,13 @@ try { const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`); + autoCheckin(asset.id); showScannedAsset(asset); + // F1: Auto check-in when scanning an existing asset + AppState.currentAssetId = asset.id; + autoCheckin(asset.id); + setScanStatus(`Checked in: ${asset.name}`, 'success'); + document.getElementById('checkinCard').style.display = 'none'; } catch (e) { if (e.message === 'Asset not found' || e.message.includes('404')) { showNewAssetForm(machineId); @@ -2754,6 +2764,16 @@ category: document.getElementById('newCatSelect').value || 'Other', status: document.getElementById('newStatus').value, }; + // F2: Auto-populate address from GPS + if (AppState.gpsLat) { + const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng); + if (geo) { + payload.address = geo.address || geo.formatted || ''; + payload.building_number = geo.building_number || ''; + payload.building_name = geo.building_name || ''; + payload.trailer_number = geo.building_number || ''; // F4 + } + } try { const asset = await api('/api/assets', { method: 'POST', @@ -2819,6 +2839,32 @@ } } + // ── Reverse geocode GPS → address (Nominatim) ───────────────────────── + async function reverseGeocode(lat, lng) { + if (!lat || !lng) return null; + try { + const data = await api(`/api/geocode?lat=${lat}&lng=${lng}`); + return data; // { address, building_number, building_name, ... } + } catch (e) { + console.warn('Reverse geocode failed:', e); + return null; + } + } + + // ── Populate address fields from geocode result ──────────────────────── + function applyGeocodeToFields(geo, prefix = 'man') { + if (!geo) return; + const set = (suffix, val) => { + const el = document.getElementById(prefix + suffix); + if (el && !el.value) el.value = val || ''; + }; + set('Address', geo.address || geo.formatted || ''); + set('BuildingNumber', geo.building_number || ''); + set('BuildingName', geo.building_name || ''); + set('TrailerNumber', geo.building_number || ''); // F4: sync building# → trailer + // Don't overwrite floor/room — those are interior details GPS can't know + } + // ═══════════════════════════════════════════════════════════════════════ // OCR MODE // ═══════════════════════════════════════════════════════════════════════ @@ -2918,15 +2964,26 @@ showToast('Machine ID and name are required', true); return; } + const payload = { + machine_id: machineId, name, + category: document.getElementById('ocrCatSelect').value || 'Other', + status: document.getElementById('ocrStatus').value, + }; + // F2: Auto-populate address from GPS + if (AppState.gpsLat) { + const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng); + if (geo) { + payload.address = geo.address || geo.formatted || ''; + payload.building_number = geo.building_number || ''; + payload.building_name = geo.building_name || ''; + payload.trailer_number = geo.building_number || ''; + } + } try { const asset = await api('/api/assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - machine_id: machineId, name, - category: document.getElementById('ocrCatSelect').value || 'Other', - status: document.getElementById('ocrStatus').value, - }), + body: JSON.stringify(payload), }); showToast('Asset created!'); document.getElementById('ocrCreateCard').style.display = 'none'; @@ -3133,6 +3190,15 @@ else showToast('Enter a map link first', true); } + function fillGpsParking(inputId) { + if (!AppState.gpsLat) { + showToast('GPS not available — allow location access', true); + return; + } + document.getElementById(inputId).value = AppState.gpsLat.toFixed(6) + ', ' + AppState.gpsLng.toFixed(6); + showToast('GPS coords filled!'); + } + // Settings are loaded after auth (in doLogin / initAuth) // ═══════════════════════════════════════════════════════════════════════ @@ -3524,7 +3590,10 @@
- +
+ + +