Skip to main content

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/<date>
  - /api/points/<date>
  - /api/stops/<date>

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,datetime, timezone
from flask import Flask,Flask, jsonify,jsonify, render_template,render_template, request

DB_PATH = "/opt/routetrack/data/routetrack.sqlite"
app = Flask(__name__)Flask(__name__)

def db(db():
    conn = sqlite3.connect(DB_PATH,sqlite3.connect(DB_PATH, timeout=10)timeout=10)
    conn.conn.row_factory = sqlite3.sqlite3.Row
    return conn

def utc_now_iso(utc_now_iso():
    # ISO-8601 with Z suffix (matches gps_points ts style)
    return datetime.now(timezone.utc)datetime.replace(microsecond=0)now(timezone.isoformat(utc).replace(replace(microsecond=0).isoformat().replace("+00:00", "Z")

# -------------------------
# UI
# -------------------------
@app.route(@app.route("/")
def index(index():
    return render_template(render_template("index.html")

# -------------------------
# Health (optional)
# -------------------------
@app.route(@app.route("/api/health")
def api_health(api_health():
    try:try:
        conn = db(db()
        conn.execute(conn.execute("SELECT 1;")
        conn.close(conn.close()
        return jsonify(jsonify({"ok": True}True})
    except Exception as e:e:
        return jsonify(jsonify({"ok": False,False, "error": str(e)str(e)}), 500

# -------------------------
# Route data endpoints
# -------------------------
@app.route(@app.route("/api/summary/<day>")
def api_summary(day)api_summary(day):
    conn = db(db()
    cur = conn.cursor(conn.cursor()
    cur.execute(cur.execute("SELECT * FROM daily_summary WHERE date = ?", (day,day,))
    row = cur.fetchone(cur.fetchone()
    conn.close(conn.close()

    if not row:row:
        return jsonify(jsonify({"error": "No summary for this date"}), 404

    return jsonify(dict(row)jsonify(dict(row))

@app.route(@app.route("/api/points/<day>")
def api_points(day)api_points(day):
    conn = db(db()
    cur = conn.cursor(conn.cursor()

    start = f"{day}day}T00:00:00Z"
    end = f"{day}day}T23:59:59Z"

    cur.execute(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,start, end)end))

    rows = cur.fetchall(cur.fetchall()
    conn.close(conn.close()

    points = [[r[r["lat"], r[r["lon"]] for r in rows]rows]
    return jsonify(points)jsonify(points)

@app.route(@app.route("/api/stops/<day>")
def api_stops(day)api_stops(day):
    conn = db(db()
    cur = conn.cursor(conn.cursor()

    start = f"{day}day}T00:00:00Z"
    end = f"{day}day}T23:59:59Z"

    cur.execute(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,start, end)end))

    rows = cur.fetchall(cur.fetchall()
    conn.close(conn.close()

    return jsonify(jsonify([dict(r)dict(r) for r in rows]rows])

# -------------------------
# Shift endpoints
# -------------------------
@app.route(@app.route("/api/shift/active")
def api_shift_active(api_shift_active():
    conn = db(db()
    cur = conn.cursor(conn.cursor()
    cur.execute(cur.execute("""
        SELECT id, start_ts, end_ts
        FROM shifts
        WHERE end_ts IS NULL
        ORDER BY id DESC
        LIMIT 1
    """)
    row = cur.fetchone(cur.fetchone()
    conn.close(conn.close()

    if not row:row:
        return jsonify(jsonify({"active": False}False})

    return jsonify(jsonify({
        "active": True,True,
        "id": row[row["id"],
        "start_ts": row[row["start_ts"],
        "end_ts": row[row["end_ts"],
    })

@app.route(@app.route("/api/shift/start", methods=methods=["POST"])
def api_shift_start(api_shift_start():
    # If already active, do nothing (idempotent-ish)
    conn = db(db()
    cur = conn.cursor(conn.cursor()

    cur.execute(cur.execute("SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;")
    existing = cur.fetchone(cur.fetchone()
    if existing:existing:
        conn.close(conn.close()
        return jsonify(jsonify({"ok": True,True, "message": "Shift already active", "id": existing[existing["id"], "start_ts": existing[existing["start_ts"]})

    start_ts = utc_now_iso(utc_now_iso()
    cur.execute(cur.execute("INSERT INTO shifts (start_ts) VALUES (?);", (start_ts,start_ts,))
    conn.commit(conn.commit()

    cur.execute(cur.execute("SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;")
    row = cur.fetchone(cur.fetchone()
    conn.close(conn.close()

    return jsonify(jsonify({"ok": True,True, "message": "Shift started", "id": row[row["id"], "start_ts": row[row["start_ts"]})

@app.route(@app.route("/api/shift/stop", methods=methods=["POST"])
def api_shift_stop(api_shift_stop():
    conn = db(db()
    cur = conn.cursor(conn.cursor()

    cur.execute(cur.execute("SELECT id, start_ts FROM shifts WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1;")
    row = cur.fetchone(cur.fetchone()
    if not row:row:
        conn.close(conn.close()
        return jsonify(jsonify({"ok": False,False, "error": "No active shift."}), 400

    end_ts = utc_now_iso(utc_now_iso()
    cur.execute(cur.execute("UPDATE shifts SET end_ts = ? WHERE id = ?;", (end_ts,end_ts, row[row["id"]))
    conn.commit(conn.commit()
    conn.close(conn.close()

    return jsonify(jsonify({"ok": True,True, "message": "Shift ended", "id": row[row["id"], "start_ts": row[row["start_ts"], "end_ts": end_ts}end_ts})

@app.route(@app.route("/api/shift/summary")
def api_shift_summary(api_shift_summary():
    conn = db(db()
    cur = conn.cursor(conn.cursor()

    cur.execute(cur.execute("""
        SELECT id, start_ts, end_ts
        FROM shifts
        WHERE end_ts IS NULL
        ORDER BY id DESC
        LIMIT 1
    """)
    shift = cur.fetchone(cur.fetchone()
    conn.close(conn.close()

    if not shift:shift:
        return jsonify(jsonify({"error": "No active shift."}), 404

    # Simple summary for the UI (elapsed seconds)
    start_dt = datetime.fromisoformat(shift[datetime.fromisoformat(shift["start_ts"].replace(replace("Z", "+00:00"))
    now_dt = datetime.now(timezone.utc)datetime.now(timezone.utc)
    elapsed_s = int(int((now_dt - start_dt)start_dt).total_seconds(total_seconds())

    return jsonify(jsonify({
        "active": True,True,
        "id": shift[shift["id"],
        "start_ts": shift[shift["start_ts"],
        "elapsed_seconds": elapsed_s
    })

if __name__ == "__main__":
    app.run(host=app.run(host="0.0.0.0", port=5000,port=5000, debug=False)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:

<!doctype html>
<html>
<head>
  <meta charset=charset="utf-8"8" />
  <title>RouteTrack Dashboard</title>
  <meta name=name="viewport"viewport" content=content="width=device-width, initial-scale=1"1" />

  <!-- Leaflet (CDN) -->
  <link rel=rel="stylesheet"stylesheet" href=href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"css"/>
  <script src=src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"js"></script>

  <style>
    :root {
      --bg:bg: #0f0f0f;#0f0f0f;
      --panel:panel: #151515;#151515;
      --text:text: #f2f2f2;#f2f2f2;
      --muted:muted: #bdbdbd;#bdbdbd;
      --ok:ok: #16a34a;#16a34a;
      --stop:stop: #dc2626;#dc2626;
      --warn:warn: #f59e0b;#f59e0b;
      --card:card: #1c1c1c;#1c1c1c;
      --border:border: #2b2b2b;#2b2b2b;
    }

    body { margin:margin: 0;0; font-family:family: Arial,Arial, sans-serif;serif; background:background: var(var(--bg)bg); color:color: var(var(--text)text); }
    #topbar {
      padding:padding: 10px 12px;12px;
      background:background: var(var(--panel)panel);
      color:color: var(var(--text)text);
      display:display: flex;flex;
      gap:gap: 10px;10px;
      align-items:items: center;center;
      flex-wrap:wrap: wrap;wrap;
      border-bottom:bottom: 1px solid var(var(--border)border);
    }

    #brand { font-weight:weight: 700;700; }
    #statusBadge {
      padding:padding: 4px 10px;10px;
      border-radius:radius: 999px;999px;
      font-size:size: 12px;12px;
      border:border: 1px solid var(var(--border)border);
      background:background: var(var(--card)card);
    }
    .badge-active { border-color:color: rgba(22,163,74,rgba(22,163,74,.6)6); }
    .badge-stopped { border-color:color: rgba(220,38,38,rgba(220,38,38,.6)6); }

    #topControls {
      margin-left:left: auto;auto;
      display:display: flex;flex;
      gap:gap: 8px;8px;
      align-items:items: center;center;
    }

    input[type="date"]{
      background:background: var(var(--card)card);
      color:color: var(var(--text)text);
      border:border: 1px solid var(var(--border)border);
      border-radius:radius: 8px;8px;
      padding:padding: 6px 8px;8px;
    }

    button {
      border:border: 0;0;
      padding:padding: 9px 12px;12px;
      border-radius:radius: 10px;10px;
      font-weight:weight: 700;700;
      cursor:cursor: pointer;pointer;
    }
    button:disabled { opacity:opacity: 0.45;45; cursor:cursor: not-allowed;allowed; }

    .btn { background:background: #2a2a2a;#2a2a2a; color:color: var(var(--text)text); border:border: 1px solid var(var(--border)border); }
    .btnStart { background:background: var(var(--ok)ok); color:color: #fff;#fff; }
    .btnStop { background:background: var(var(--stop)stop); color:color: #fff;#fff; }

    #map { height:height: 62vh;62vh; }

    #content {
      padding:padding: 12px;12px;
      display:display: grid;grid;
      gap:gap: 12px;12px;
    }

    .card {
      background:background: var(var(--card)card);
      border:border: 1px solid var(var(--border)border);
      border-radius:radius: 14px;14px;
      padding:padding: 12px;12px;
    }

    h3 { margin:margin: 0 0 8px 0;0; }
    .row { margin:margin: 6px 0;0; color:color: var(var(--muted)muted); }
    code { background:background: #232323;#232323; padding:padding: 2px 6px;6px; border-radius:radius: 6px;6px; color:color: #fff;#fff; }

    /* Sticky bottom control bar for mobile */
    #shiftBar {
      position:position: sticky;sticky;
      bottom:bottom: 0;0;
      background:background: rgba(15,15,15,rgba(15,15,15,.92)92);
      backdrop-filter:filter: blur(8px)blur(8px);
      border-top:top: 1px solid var(var(--border)border);
      padding:padding: 10px 12px;12px;
      display:display: flex;flex;
      gap:gap: 10px;10px;
      z-index:index: 999;999;
    }
    #shiftBar button {
      flex:flex: 1;1;
      padding:padding: 14px 12px;12px;
      border-radius:radius: 14px;14px;
      font-size:size: 16px;16px;
    }

    /* Toast */
    #toast {
      position:position: fixed;fixed;
      left:left: 50%;
      transform:transform: translateX(translateX(-50%);
      bottom:bottom: 86px;86px;
      background:background: #111;#111;
      border:border: 1px solid var(var(--border)border);
      color:color: var(var(--text)text);
      padding:padding: 10px 12px;12px;
      border-radius:radius: 12px;12px;
      display:display: none;none;
      z-index:index: 1000;1000;
      max-width:width: 92vw;92vw;
    }
    #toast.ok { border-color:color: rgba(22,163,74,rgba(22,163,74,.7)7); }
    #toast.err { border-color:color: rgba(220,38,38,rgba(220,38,38,.7)7); }
    #toast.warn { border-color:color: rgba(245,158,11,rgba(245,158,11,.7)7); }

    @media (min-width:width: 900px)900px) {
      #map { height:height: 70vh;70vh; }
      #shiftBar { width:width: 520px;520px; margin:margin: 0 auto 12px auto;auto; border-radius:radius: 14px;14px; }
    }
  </style>
</head>

<body>
  <div id=id="topbar"topbar">
    <span id=id="brand"brand">RouteTrack</span>
    <span id=id="statusBadge"statusBadge" class=class="badge-stopped"stopped">🔴 SHIFT STOPPED</span>

    <div id=id="topControls"topControls">
      <span style=style="color:color: var(var(--muted)muted); font-size:size: 12px;12px;">Date</span>
      <input id=id="day"day" type=type="date"date" />
      <button class=class="btn"btn" onclick=onclick="loadAll()">Reload</button>
    </div>
  </div>

  <div id=id="map"map"></div>

  <div id=id="content"content">
    <div class=class="card"card">
      <h3>Active Shift</h3>
      <div id=id="shiftCard"shiftCard" class=class="row"row">Checking shift status…</div>
    </div>

    <div class=class="card"card">
      <h3>Daily Summary</h3>
      <div id=id="summary"summary" class=class="row"row">Loading…</div>
    </div>

    <div class=class="card"card">
      <h3>Stops</h3>
      <div id=id="stops"stops" class=class="row"row">Loading…</div>
    </div>
  </div>

  <!-- Sticky mobile shift controls -->
  <div id=id="shiftBar"shiftBar">
    <button id=id="btnStart"btnStart" class=class="btnStart"btnStart" onclick=onclick="startShift()">Start Shift</button>
    <button id=id="btnStop"btnStop" class=class="btnStop"btnStop" onclick=onclick="stopShift()">Stop Shift</button>
  </div>

  <div id=id="toast"toast"></div>

<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: "&copy; 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=class="row"row">Active shift detected. Start: <code>${data.start_ts}</code></div>`;
        return;
      }
      const mins = Math.floor((sum.elapsed_seconds || 0) / 60);
      document.getElementById("shiftCard").innerHTML =
        `<div class=class="row"row">Started: <code>${sum.start_ts}</code></div>
         <div class=class="row"row">Elapsed: <strong>${mins}</strong> min</div>`;
    } 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=class='row'row'>No stops found.</div>";
      return;
    }

    stops.forEach(s => {
      const durMin = Math.round((s.duration_seconds || 0) / 60);
      stopsDiv.innerHTML += `<div class=class="row"row">
        Stop: <code>${s.start_ts}</code><code>${s.end_ts}</code>
        (${durMin} min)
      </div>`;

      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=class="row"row">No summary for ${day}. Run processor first.</div>`;
      return;
    }

    summaryDiv.innerHTML = `
      <div class=class="row"row">Start: <code>${data.start_ts}</code></div>
      <div class=class="row"row">End: <code>${data.end_ts}</code></div>
      <div class=class="row"row">Distance: <strong>${data.total_distance_miles}</strong> miles</div>
      <div class=class="row"row">Moving: <strong>${Math.round(data.moving_time_seconds/60)}</strong> minutes</div>
      <div class=class="row"row">Stopped: <strong>${Math.round(data.stopped_time_seconds/60)}</strong> minutes</div>
      <div class=class="row"row">Stops: <strong>${data.stop_count}</strong></div>
    `;
  }

  // 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);
  })();
</script>
</body>
</html>

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

  1. Add “Shift view” mode (show only points within the active shift window)

  2. Add start/stop shift button inside the map (floating control)

  3. Improve stop detection with:

    • ignition off detection (optional)

    • drift suppression using epx/epy thresholds