12 - RouteTrack Pi — Mobile UX Upgrade (Shift Controls + Status) Date: December 25th, 2025 Category: Raspberry Pi / GPS / Web UI / UX Backlink: 08 — RouteTrack Pi — Local Dashboard (Leaflet + Flask) Project Goal This update makes the RouteTrack dashboard easy and safe to use from a phone , especially since this Pi will be: powered down frequently used on the go accessed quickly before/after driving The dashboard becomes a real operator UI : you can glance and instantly know whether a shift is active you can start/stop with big buttons (no scrolling) the UI recovers cleanly after reboot What We Added (UX Features) 1) Shift Status Badge (Top Bar) Shows: 🟢 ACTIVE (with start time) 🔴 STOPPED 2) Sticky Shift Controls (Bottom Bar) Large buttons designed for mobile thumbs: Start Shift (green) Stop Shift (red) 3) Button Safety Rules If shift is ACTIVE → Start disabled If shift is STOPPED → Stop disabled 4) Inline Toast Messages Non-blocking confirmations like: “Shift started” “Shift ended” “API not reachable yet” (during boot) 5) Auto Refresh After Start/Stop After changing shift state, the UI automatically refreshes: route line stops daily summary shift card File Updates Update  app.py (Shift endpoints + existing APIs) Replace the current  /opt/routetrack/web/app.py with this full version. sudo nano /opt/routetrack/web/app.py Paste: #!/usr/bin/env python3 """ RouteTrack Local Dashboard (Flask) ---------------------------------- Provides: - Web UI page (Leaflet map) - JSON API endpoints (read-only route data): - /api/summary/ - /api/points/ - /api/stops/ Shift Control Endpoints (write minimal shift state only): - GET /api/shift/active - POST /api/shift/start - POST /api/shift/stop - GET /api/shift/summary Notes: - Route endpoints are READ-ONLY from gps_points/stop_events/daily_summary. - Shift endpoints write only to the shifts table. """ import sqlite3 from datetime import datetime , timezone from flask import Flask , jsonify , render_template , request DB_PATH = "/opt/routetrack/data/routetrack.sqlite" app = Flask ( __name__ ) def db ( ) : conn = sqlite3 . connect ( DB_PATH , timeout = 10 ) conn . row_factory = sqlite3 . Row return conn def utc_now_iso ( ) : # ISO-8601 with Z suffix (matches gps_points ts style) return datetime . now ( timezone . utc ) . replace ( microsecond = 0 ) . isoformat ( ) . replace ( "+00:00" , "Z" ) # ------------------------- # UI # ------------------------- @app . route ( "/" ) def index ( ) : return render_template ( "index.html" ) # ------------------------- # Health (optional) # ------------------------- @app . route ( "/api/health" ) def api_health ( ) : try : conn = db ( ) conn . execute ( "SELECT 1;" ) conn . close ( ) return jsonify ( { "ok" : True } ) except Exception as e : return jsonify ( { "ok" : False , "error" : str ( e ) } ) , 500 # ------------------------- # Route data endpoints # ------------------------- @app . route ( "/api/summary/" ) def api_summary ( day ) : conn = db ( ) cur = conn . cursor ( ) cur . execute ( "SELECT * FROM daily_summary WHERE date = ?" , ( day , ) ) row = cur . fetchone ( ) conn . close ( ) if not row : return jsonify ( { "error" : "No summary for this date" } ) , 404 return jsonify ( dict ( row ) ) @app . route ( "/api/points/" ) def api_points ( day ) : conn = db ( ) cur = conn . cursor ( ) start = f" { day } T00:00:00Z" end = f" { day } T23:59:59Z" cur . execute ( """ SELECT ts, lat, lon FROM gps_points WHERE ts >= ? AND ts <= ? AND mode = 3 AND lat IS NOT NULL AND lon IS NOT NULL ORDER BY ts """ , ( start , end ) ) rows = cur . fetchall ( ) conn . close ( ) points = [ [ r [ "lat" ] , r [ "lon" ] ] for r in rows ] return jsonify ( points ) @app . route ( "/api/stops/" ) def api_stops ( day ) : conn = db ( ) cur = conn . cursor ( ) start = f" { day } T00:00:00Z" end = f" { day } T23:59:59Z" cur . execute ( """ SELECT start_ts, end_ts, duration_seconds, lat, lon FROM stop_events WHERE start_ts >= ? AND start_ts <= ? ORDER BY start_ts """ , ( start , end ) ) rows = cur . fetchall ( ) conn . close ( ) return jsonify ( [ dict ( r ) for r in rows ] ) # ------------------------- # Shift endpoints # ------------------------- @app . route ( "/api/shift/active" ) def api_shift_active ( ) : conn = db ( ) cur = conn . cursor ( ) cur . execute ( """ SELECT id, start_ts, end_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1 """ ) row = cur . fetchone ( ) conn . close ( ) if not row : return jsonify ( { "active" : False } ) return jsonify ( { "active" : True , "id" : row [ "id" ] , "start_ts" : row [ "start_ts" ] , "end_ts" : row [ "end_ts" ] , } ) @app . route ( "/api/shift/start" , methods = [ "POST" ] ) def api_shift_start ( ) : # If already active, do nothing (idempotent-ish) conn = db ( ) cur = conn . cursor ( ) cur . execute ( "SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;" ) existing = cur . fetchone ( ) if existing : conn . close ( ) return jsonify ( { "ok" : True , "message" : "Shift already active" , "id" : existing [ "id" ] , "start_ts" : existing [ "start_ts" ] } ) start_ts = utc_now_iso ( ) cur . execute ( "INSERT INTO shifts (start_ts) VALUES (?);" , ( start_ts , ) ) conn . commit ( ) cur . execute ( "SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;" ) row = cur . fetchone ( ) conn . close ( ) return jsonify ( { "ok" : True , "message" : "Shift started" , "id" : row [ "id" ] , "start_ts" : row [ "start_ts" ] } ) @app . route ( "/api/shift/stop" , methods = [ "POST" ] ) def api_shift_stop ( ) : conn = db ( ) cur = conn . cursor ( ) cur . execute ( "SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;" ) row = cur . fetchone ( ) if not row : conn . close ( ) return jsonify ( { "ok" : False , "error" : "No active shift." } ) , 400 end_ts = utc_now_iso ( ) cur . execute ( "UPDATE shifts SET end_ts = ? WHERE id = ?;" , ( end_ts , row [ "id" ] ) ) conn . commit ( ) conn . close ( ) return jsonify ( { "ok" : True , "message" : "Shift ended" , "id" : row [ "id" ] , "start_ts" : row [ "start_ts" ] , "end_ts" : end_ts } ) @app . route ( "/api/shift/summary" ) def api_shift_summary ( ) : conn = db ( ) cur = conn . cursor ( ) cur . execute ( """ SELECT id, start_ts, end_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1 """ ) shift = cur . fetchone ( ) conn . close ( ) if not shift : return jsonify ( { "error" : "No active shift." } ) , 404 # Simple summary for the UI (elapsed seconds) start_dt = datetime . fromisoformat ( shift [ "start_ts" ] . replace ( "Z" , "+00:00" ) ) now_dt = datetime . now ( timezone . utc ) elapsed_s = int ( ( now_dt - start_dt ) . total_seconds ( ) ) return jsonify ( { "active" : True , "id" : shift [ "id" ] , "start_ts" : shift [ "start_ts" ] , "elapsed_seconds" : elapsed_s } ) if __name__ == "__main__" : app . run ( host = "0.0.0.0" , port = 5000 , debug = False ) Make executable (optional, harmless): sudo chmod +x /opt/routetrack/web/app.py Restart dashboard: sudo systemctl restart routetrack-dashboard.service Quick verify: curl http://localhost:5000/api/shift/active ✅ Update index.html (Mobile UI + sticky controls + status) Edit: sudo nano /opt/routetrack/web/templates/index.html Paste the full file: < html > < head > < meta charset = " utf-8 " /> < title > RouteTrack Dashboard < meta name = " viewport " content = " width=device-width, initial-scale=1 " /> < link rel = " stylesheet " href = " https://unpkg.com/leaflet@1.9.4/dist/leaflet.css " /> < script src = " https://unpkg.com/leaflet@1.9.4/dist/leaflet.js " > < style > :root { --bg : #0f0f0f ; --panel : #151515 ; --text : #f2f2f2 ; --muted : #bdbdbd ; --ok : #16a34a ; --stop : #dc2626 ; --warn : #f59e0b ; --card : #1c1c1c ; --border : #2b2b2b ; } body { margin : 0 ; font-family : Arial , sans-serif ; background : var ( --bg ) ; color : var ( --text ) ; } #topbar { padding : 10px 12px ; background : var ( --panel ) ; color : var ( --text ) ; display : flex ; gap : 10px ; align-items : center ; flex-wrap : wrap ; border-bottom : 1px solid var ( --border ) ; } #brand { font-weight : 700 ; } #statusBadge { padding : 4px 10px ; border-radius : 999px ; font-size : 12px ; border : 1px solid var ( --border ) ; background : var ( --card ) ; } .badge-active { border-color : rgba ( 22 , 163 , 74 , .6 ) ; } .badge-stopped { border-color : rgba ( 220 , 38 , 38 , .6 ) ; } #topControls { margin-left : auto ; display : flex ; gap : 8px ; align-items : center ; } input[type="date"] { background : var ( --card ) ; color : var ( --text ) ; border : 1px solid var ( --border ) ; border-radius : 8px ; padding : 6px 8px ; } button { border : 0 ; padding : 9px 12px ; border-radius : 10px ; font-weight : 700 ; cursor : pointer ; } button:disabled { opacity : 0.45 ; cursor : not-allowed ; } .btn { background : #2a2a2a ; color : var ( --text ) ; border : 1px solid var ( --border ) ; } .btnStart { background : var ( --ok ) ; color : #fff ; } .btnStop { background : var ( --stop ) ; color : #fff ; } #map { height : 62vh ; } #content { padding : 12px ; display : grid ; gap : 12px ; } .card { background : var ( --card ) ; border : 1px solid var ( --border ) ; border-radius : 14px ; padding : 12px ; } h3 { margin : 0 0 8px 0 ; } .row { margin : 6px 0 ; color : var ( --muted ) ; } code { background : #232323 ; padding : 2px 6px ; border-radius : 6px ; color : #fff ; } /* Sticky bottom control bar for mobile */ #shiftBar { position : sticky ; bottom : 0 ; background : rgba ( 15 , 15 , 15 , .92 ) ; backdrop-filter : blur ( 8px ) ; border-top : 1px solid var ( --border ) ; padding : 10px 12px ; display : flex ; gap : 10px ; z-index : 999 ; } #shiftBar button { flex : 1 ; padding : 14px 12px ; border-radius : 14px ; font-size : 16px ; } /* Toast */ #toast { position : fixed ; left : 50% ; transform : translateX ( -50% ) ; bottom : 86px ; background : #111 ; border : 1px solid var ( --border ) ; color : var ( --text ) ; padding : 10px 12px ; border-radius : 12px ; display : none ; z-index : 1000 ; max-width : 92vw ; } #toast.ok { border-color : rgba ( 22 , 163 , 74 , .7 ) ; } #toast.err { border-color : rgba ( 220 , 38 , 38 , .7 ) ; } #toast.warn { border-color : rgba ( 245 , 158 , 11 , .7 ) ; } @media ( min-width : 900px ) { #map { height : 70vh ; } #shiftBar { width : 520px ; margin : 0 auto 12px auto ; border-radius : 14px ; } } < body > < div id = " topbar " > < span id = " brand " > RouteTrack < span id = " statusBadge " class = " badge-stopped " > 🔴 SHIFT STOPPED < div id = " topControls " > < span style = " color : var ( --muted ) ; font-size : 12px ; " > Date < input id = " day " type = " date " /> < button class = " btn " onclick = " loadAll() " > Reload < div id = " map " > < div id = " content " > < div class = " card " > < h3 > Active Shift < div id = " shiftCard " class = " row " > Checking shift status… < div class = " card " > < h3 > Daily Summary < div id = " summary " class = " row " > Loading… < div class = " card " > < h3 > Stops < div id = " stops " class = " row " > Loading… < div id = " shiftBar " > < button id = " btnStart " class = " btnStart " onclick = " startShift() " > Start Shift < button id = " btnStop " class = " btnStop " onclick = " stopShift() " > Stop Shift < div id = " toast " > < script > // Default date = today (browser local time) const dayInput = document.getElementById("day"); dayInput.valueAsDate = new Date(); const statusBadge = document.getElementById("statusBadge"); const btnStart = document.getElementById("btnStart"); const btnStop = document.getElementById("btnStop"); const toastEl = document.getElementById("toast"); const map = L.map("map").setView([38.7153, -89.94], 13); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: " © OpenStreetMap contributors" }).addTo(map); let routeLine = null; let stopMarkers = []; function toast(msg, type="ok") { toastEl.className = ""; toastEl.classList.add(type); toastEl.textContent = msg; toastEl.style.display = "block"; setTimeout(() => toastEl.style.display = "none", 3200); } function setShiftUI(active, start_ts=null) { if (active) { statusBadge.textContent = `🟢 SHIFT ACTIVE${start_ts ? " — " + start_ts : ""}`; statusBadge.classList.remove("badge-stopped"); statusBadge.classList.add("badge-active"); btnStart.disabled = true; btnStop.disabled = false; } else { statusBadge.textContent = "🔴 SHIFT STOPPED"; statusBadge.classList.remove("badge-active"); statusBadge.classList.add("badge-stopped"); btnStart.disabled = false; btnStop.disabled = true; } } async function apiJSON(url, opts={}) { const res = await fetch(url, opts); let data = {}; try { data = await res.json(); } catch(e) {} return { res, data }; } async function refreshShiftState() { const { res, data } = await apiJSON("/api/shift/active"); if (!res.ok) { setShiftUI(false); document.getElementById("shiftCard").textContent = "Shift API not reachable yet."; return; } setShiftUI(!!data.active, data.start_ts || null); if (data.active) { // Show elapsed time quickly const { data: sum } = await apiJSON("/api/shift/summary"); if (!sum || sum.error) { document.getElementById("shiftCard").innerHTML = ` < div class = " row " > Active shift detected. Start: < code > ${data.start_ts} `; return; } const mins = Math.floor((sum.elapsed_seconds || 0) / 60); document.getElementById("shiftCard").innerHTML = ` < div class = " row " > Started: < code > ${sum.start_ts} < div class = " row " > Elapsed: < strong > ${mins} min `; } else { document.getElementById("shiftCard").textContent = "No active shift."; } } async function startShift() { btnStart.disabled = true; const { res, data } = await apiJSON("/api/shift/start", { method: "POST" }); if (!res.ok || data.ok === false) { toast(data.error || "Failed to start shift.", "err"); await refreshShiftState(); return; } toast(data.message || "Shift started.", "ok"); await refreshShiftState(); await loadAll(); // refresh route/stops/summary } async function stopShift() { // One simple safety check (no popup spam): if (!confirm("End shift now?")) return; btnStop.disabled = true; const { res, data } = await apiJSON("/api/shift/stop", { method: "POST" }); if (!res.ok || data.ok === false) { toast(data.error || "Failed to stop shift.", "err"); await refreshShiftState(); return; } toast(data.message || "Shift ended.", "ok"); await refreshShiftState(); await loadAll(); } async function loadAll() { const day = dayInput.value; await loadRoute(day); await loadStops(day); await loadSummary(day); } async function loadRoute(day) { const { res, data } = await apiJSON(`/api/points/${day}`); const pts = Array.isArray(data) ? data : []; if (routeLine) map.removeLayer(routeLine); if (!pts.length) return; routeLine = L.polyline(pts, { weight: 4 }).addTo(map); map.fitBounds(routeLine.getBounds()); } async function loadStops(day) { stopMarkers.forEach(m => map.removeLayer(m)); stopMarkers = []; const { data } = await apiJSON(`/api/stops/${day}`); const stops = Array.isArray(data) ? data : []; const stopsDiv = document.getElementById("stops"); stopsDiv.innerHTML = ""; if (!stops.length) { stopsDiv.innerHTML = " < div class = ' row ' > No stops found. "; return; } stops.forEach(s => { const durMin = Math.round((s.duration_seconds || 0) / 60); stopsDiv.innerHTML += ` < div class = " row " > Stop: < code > ${s.start_ts} → < code > ${s.end_ts} (${durMin} min) `; if (s.lat && s.lon) { const m = L.marker([s.lat, s.lon]).addTo(map) .bindPopup(`Stop (${durMin} min) < br > ${s.start_ts}`); stopMarkers.push(m); } }); } async function loadSummary(day) { const summaryDiv = document.getElementById("summary"); summaryDiv.innerHTML = ""; const { data } = await apiJSON(`/api/summary/${day}`); if (!data || data.error) { summaryDiv.innerHTML = ` < div class = " row " > No summary for ${day}. Run processor first. `; return; } summaryDiv.innerHTML = ` < div class = " row " > Start: < code > ${data.start_ts} < div class = " row " > End: < code > ${data.end_ts} < div class = " row " > Distance: < strong > ${data.total_distance_miles} miles < div class = " row " > Moving: < strong > ${Math.round(data.moving_time_seconds/60)} minutes < div class = " row " > Stopped: < strong > ${Math.round(data.stopped_time_seconds/60)} minutes < div class = " row " > Stops: < strong > ${data.stop_count} `; } // Boot behavior: shift state first, then route data (async () => { try { await refreshShiftState(); await loadAll(); } catch (e) { toast("Dashboard loading… waiting on services.", "warn"); } // Light auto-refresh of shift status every 15s setInterval(refreshShiftState, 15000); })(); Restart dashboard: sudo systemctl restart routetrack-dashboard.service Verification Confirm shift API works curl http://localhost:5000/api/shift/active Start shift: curl -X POST http://localhost:5000/api/shift/start Stop shift: curl -X POST http://localhost:5000/api/shift/stop Then load the dashboard from your phone and confirm: status badge flips correctly buttons enable/disable properly map + stats refresh after shift actions Why This UX Matters (for a portable device) This dashboard is now resilient for: frequent power-off/on cycles quick “start shift / drive / stop shift” workflows using the UI one-handed on a phone It reduces mistakes and removes uncertainty — which is exactly what you want when this becomes a daily tool. Next Steps Add “Shift view” mode (show only points within the active shift window) Add start/stop shift button inside the map (floating control) Improve stop detection with: ignition off detection (optional) drift suppression using epx/epy thresholds