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)
-
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: "© 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
-
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
-