Add live auto-refresh, search/sort, health, reporting totals, and badge-times modal
Build and Push Docker Image / build (push) Successful in 6s

Dashboard usability pass:
- Auto-refresh: poll today every 45s with a live "updated Xs ago" indicator
  and toggle; auto-pauses on past dates.
- Table search, click-to-sort column headers, and clickable summary pills
  that filter by on-time / late.
- View state (date, cutoff, controller, filters) persists to localStorage and
  the URL for reloads, bookmarking, and sharing; adds a Today button.
- First-run onboarding CTA when no controllers exist, plus a header health
  pill and per-controller last-event time driven by a new last_event_at column
  (stamped on webhook ingest; auto-migrated on boot).
- Report pivot gains per-person on-time/late/absent totals and an All row;
  CSV export now downloads via fetch/blob so failures surface as errors.
- New badge-in times modal (GET /api/badge-events) listing every badge-in for
  a person on a day — across all identities for merged people.

Docs updated to cover all of the above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-01 23:14:03 -05:00
parent d36ba4ee73
commit 202d51753e
3 changed files with 640 additions and 128 deletions
+61 -1
View File
@@ -59,7 +59,8 @@ def init_db():
webhook_id TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
last_sync_at TEXT
last_sync_at TEXT,
last_event_at TEXT
)
"""
)
@@ -104,6 +105,10 @@ def init_db():
"ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0"
)
# Migrate legacy controllers that pre-date the last_event_at column.
if not _column_exists(db, "controllers", "last_event_at"):
db.execute("ALTER TABLE controllers ADD COLUMN last_event_at TEXT")
db.execute(
"""
CREATE TABLE IF NOT EXISTS persons (
@@ -314,6 +319,7 @@ def controller_to_dict(row):
"enabled": bool(row["enabled"]),
"has_webhook": bool(row["webhook_id"]),
"last_sync_at": row["last_sync_at"],
"last_event_at": row["last_event_at"],
}
@@ -399,6 +405,56 @@ def first_badge_status():
return jsonify(result)
@app.route("/api/badge-events")
def badge_events_for_subject():
"""All badge-in events for one subject on one day, oldest first. A subject is
either a merged person (person_id) or a single actor (controller_id+actor_id)."""
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
person_id = request.args.get("person_id", "").strip() or None
ctrl_id = request.args.get("controller_id", "").strip() or None
actor_id = request.args.get("actor_id", "").strip() or None
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date):
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
with get_db() as db:
if person_id:
members = db.execute(
"SELECT controller_id, actor_id FROM person_members WHERE person_id = ?",
(person_id,),
).fetchall()
pairs = [(m["controller_id"], m["actor_id"]) for m in members]
elif ctrl_id and actor_id:
pairs = [(ctrl_id, actor_id)]
else:
return jsonify({"error": "person_id, or controller_id + actor_id, required"}), 400
if not pairs:
return jsonify([])
placeholders = ",".join(["(?,?)"] * len(pairs))
sql = f"""
SELECT b.ts, b.controller_id, b.actor_id, c.name AS source
FROM badge_events b
LEFT JOIN controllers c ON c.id = b.controller_id
WHERE b.date = ?
AND (b.controller_id, b.actor_id) IN ({placeholders})
ORDER BY b.ts ASC
"""
params = [date] + [v for pair in pairs for v in pair]
rows = db.execute(sql, params).fetchall()
return jsonify([
{
"ts": r["ts"],
"controller_id": r["controller_id"],
"actor_id": r["actor_id"],
"source": r["source"] or "",
}
for r in rows
])
# ---------------------------------------------------------------------------
# Reporting — first badge-in times over a date range
# ---------------------------------------------------------------------------
@@ -678,6 +734,10 @@ def _ingest_webhook(controller_id):
"INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
(controller_id, actor, ts_str, date),
)
db.execute(
"UPDATE controllers SET last_event_at = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), controller_id),
)
db.commit()
log.info(