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
+49 -13
View File
@@ -18,9 +18,18 @@ first-badge time per human, not per badge UUID — no more false LATE warnings
for staff who badge into multiple buildings before the cutoff. (05/28/26)
**Reporting:** export first badge-in times over a date range for one person or a
multi-selected group. View an on-screen pivot (users × dates) or download a
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles
as an attendance report. (06/19/26)
multi-selected group. View an on-screen pivot (users × dates) with per-person
on-time / late / absent totals, or download a long-format CSV. Days with no
badge-in are reported as **absent**, so it doubles as an attendance report. (06/19/26)
**Live & searchable:** the table auto-refreshes on a 45-second interval with a
live "updated Xs ago" indicator, plus a header **health pill** that shows when the
last badge event actually arrived — so a dead webhook is obvious at a glance.
Filter rows by name, click any column header to sort, and click the summary pills
to show only on-time or late people. View settings (date, cutoff, controller,
filters) persist across reloads and are encoded in the URL for bookmarking and
sharing. Click any person's badge time — or their **Times** button — to see
**every** badge-in for that day, not just first and latest. (07/01/26)
---
@@ -183,8 +192,11 @@ Per-controller actions in the modal:
| **Date picker** | Choose which day to view (defaults to the browser's local date) |
| **Badged in by** | Set your on-time cutoff (HH:MM) |
| **Controller** | Filter the table to one controller, or show All |
| **Search** | Filter the visible rows by name (client-side, instant) |
| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) |
| **Refresh** | Reload the table |
| **Auto-refresh** | Toggle live 45-second polling of the current day (auto-pauses when viewing a past date) |
| **Today** | Jump the date picker back to today |
| **Refresh** | Reload the table now |
| **Sync Users** | Pull latest users from every enabled controller |
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
| **📊 Report** | Open the date-range report builder (first badge-in per day, per user, CSV export) |
@@ -196,14 +208,18 @@ Per-controller actions in the modal:
| Column | Description |
|---|---|
| **#** | Row number |
| **#** | Row number (within the current sort/filter) |
| **Name** | Resolved display name from UniFi Access |
| **Source** | Controller this badge event came from |
| **First Badge In** | Earliest door entry for the day — never changes once set |
| **First Badge In** | Earliest door entry for the day — never changes once set. **Click it** (or the Times button) to see the full list of the day's badge-ins |
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
| **Actor ID** | First 8 characters of the UniFi user UUID |
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
| **Actions** | **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
| **Actions** | **Times** opens every badge-in for that person on the selected day; **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
> Click a sortable header (Name, First/Latest Badge In, Status) to sort by that
> column; click again to reverse. Click the **on time** / **late** / **total**
> summary pills to show only that group.
> Once two identities are merged, the Source column shows a chip for every
> controller the person badged into that day, plus a "MERGED" pill so it's
@@ -211,6 +227,23 @@ Per-controller actions in the modal:
### Interface notes
- **Auto-refresh & live status.** When viewing today with Auto-refresh on, the
table reloads every 45 seconds. The pill in the summary row shows *"updated Xs
ago"* (or *"paused"* / *"viewing past day"* when polling is off).
- **Ingestion health.** The pill in the header shows the freshest badge event
across all enabled controllers — *"last badge 3m ago"* (green), *"no events…"*
(amber, stale), or *"webhook not registered"* — so a broken webhook is caught
without digging into the Controllers modal (which also shows per-controller
last-event time).
- **Badge-in times.** Clicking a person's First-badge time or their **Times**
button lists every badge-in for that day, oldest first, tagged *first* and
*latest* and labelled with the source controller. For merged people the list
spans all their identities.
- **View persistence & sharing.** Date, cutoff, controller, and the filter
toggles are saved to the browser and mirrored into the URL, so reloading keeps
your view and you can bookmark or share a link to a specific day/controller.
- **First-run onboarding.** With no controllers configured, the table shows an
"Add a Controller" call-to-action instead of an empty grid.
- On narrow screens, the attendance table automatically changes into stacked
row cards with labels for each field, so all columns remain visible without
sideways scrolling.
@@ -289,7 +322,8 @@ In the report modal:
- **Run Report** renders an on-screen **pivot**: one row per selected user, one
column per date, each cell showing that day's first badge-in time
(green = on time, red = late). Weekends are shaded and days with no badge-in
show as **—**.
show as **—**. Trailing **On time / Late / Absent** columns total each person
over the range, and a final **All** row totals every column.
- **Export CSV** downloads the same data in **long format** — one row per
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
Excel or Sheets.
@@ -313,10 +347,11 @@ git pull
/usr/bin/docker compose up -d --build
```
The SQLite database in `./data/` persists across rebuilds. On first start after
upgrading from a single-controller install, existing badge events are
automatically attached to the seeded "Default" controller — nothing to migrate
by hand.
The SQLite database in `./data/` persists across rebuilds. Schema changes (such
as the `last_event_at` column added for ingestion health) are applied
automatically on boot. On first start after upgrading from a single-controller
install, existing badge events are automatically attached to the seeded
"Default" controller — nothing to migrate by hand.
---
@@ -331,6 +366,7 @@ reverse proxy with auth in front of it.
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?`, `include_filtered?` | Returns first + latest badge per user (filtered tenants hidden unless `include_filtered=1`) |
| `GET` | `/api/badge-events` | `date`, and either `person_id` **or** `controller_id`+`actor_id` | Every badge-in for one subject on one day, oldest first (for a merged person, across all their identities) |
| `GET` | `/api/report/subjects` | `controller_id?`, `include_filtered?` | List selectable report subjects (merged persons + unmerged actors), each with a stable `key` |
| `GET` | `/api/report` | `start`, `end`, `cutoff?`, `subjects?`, `controller_id?`, `include_filtered?`, `format?` | First badge-in per subject per day over the range. JSON (pivot-shaped) by default; `format=csv` returns a long-format download. Missing days reported as `ABSENT`. `subjects` is a comma-separated list of subject `key`s (empty = all) |
| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag |
@@ -342,7 +378,7 @@ reverse proxy with auth in front of it.
| `POST` | `/api/persons/<id>/members` | `controller_id`, `actor_id` | Add another identity to an existing person |
| `DELETE` | `/api/persons/<id>/members/<cid>/<aid>` | — | Split one identity off; dissolves the person if it was the last member |
| `GET` | `/api/persons/suggestions` | — | Exact full-name matches across controllers, excluding already-merged actors |
| `GET` | `/api/controllers` | — | List configured controllers |
| `GET` | `/api/controllers` | — | List configured controllers (includes `last_sync_at` and `last_event_at` for health) |
| `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) |
| `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |
+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(
+459 -43
View File
@@ -25,6 +25,12 @@
padding: 24px 24px 32px;
}
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; }
.header-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.health-pill {
display: inline-flex; align-items: center; gap: 8px;
background: var(--bg-card); border-radius: 999px; padding: 6px 14px;
border: 1px solid var(--border); color: var(--muted); font-size: 0.78rem;
}
.title-block { display: flex; flex-direction: column; gap: 4px; }
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
.subtitle { font-size: 0.9rem; color: var(--muted); }
@@ -57,12 +63,21 @@
.controllers-btn{ border-color: rgba(160,120,255,0.6); background: radial-gradient(circle at top, rgba(160,120,255,0.18), rgba(2,2,4,0.95)); }
.reset-btn { border-color: rgba(255,100,100,0.6); background: radial-gradient(circle at top, rgba(255,80,80,0.18), rgba(2,2,4,0.95)); }
.small-btn { padding: 6px 12px; font-size: 0.75rem; }
.summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; }
.summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; align-items: center; }
.summary-pill { display: inline-flex; align-items: center; gap: 8px; background: var(--bg-card); border-radius: 999px; padding: 6px 14px; border: 1px solid var(--border); color: var(--muted); }
button.summary-pill {
cursor: pointer; text-transform: none; letter-spacing: normal;
font-size: 0.85rem; transition: border-color 0.1s, background 0.1s;
}
button.summary-pill:hover { border-color: rgba(212,175,55,0.5); transform: none; box-shadow: none; }
button.summary-pill.active { border-color: var(--gold-soft); color: var(--text); background: rgba(212,175,55,0.08); }
.live-indicator { font-size: 0.76rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); }
.dot.off { background: var(--danger); box-shadow: 0 0 10px rgba(255,77,79,0.7); }
.dot.all { background: var(--gold-soft); box-shadow: 0 0 10px rgba(184,150,48,0.5); }
.dot.stale { background: var(--warn); box-shadow: 0 0 10px rgba(243,156,18,0.6); }
.dot.idle { background: var(--muted); box-shadow: none; }
.table-card {
background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98));
border-radius: 14px; border: 1px solid rgba(255,255,255,0.04); overflow: hidden;
@@ -71,6 +86,11 @@
thead { background: radial-gradient(circle at top left, rgba(212,175,55,0.22), rgba(10,10,12,0.9)); }
th, td { padding: 10px 16px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.04); white-space: nowrap; }
th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--gold-soft); }
th.sortable .sort-arrow { opacity: 0; margin-left: 4px; font-size: 0.7rem; }
th.sortable.sorted-asc .sort-arrow,
th.sortable.sorted-desc .sort-arrow { opacity: 1; color: var(--gold); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(212,175,55,0.04); }
.name-cell { font-weight: 500; color: var(--text); }
@@ -83,7 +103,20 @@
}
.align-center { text-align: center; }
.time-first { color: var(--text); font-weight: 500; }
.time-first.clickable { cursor: pointer; text-decoration: underline dotted rgba(212,175,55,0.5); text-underline-offset: 3px; }
.time-first.clickable:hover { color: var(--gold); }
.time-latest { color: var(--muted); font-size: 0.85rem; }
.times-list { display: flex; flex-direction: column; gap: 6px; max-height: 360px; overflow-y: auto; margin: 10px 0 4px; }
.time-entry {
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
padding: 9px 14px; background: rgba(255,255,255,0.02);
border: 1px solid var(--border); border-radius: 10px;
}
.time-entry .t-idx { color: var(--muted); font-size: 0.78rem; min-width: 1.4em; }
.time-entry .t-time { font-family: ui-monospace, monospace; font-size: 0.95rem; color: var(--text); }
.time-entry.is-first .t-time { color: var(--gold); font-weight: 600; }
.time-entry .t-tag { font-size: 0.62rem; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--gold-soft); border: 1px solid rgba(212,175,55,0.4); border-radius: 999px; padding: 1px 7px; margin-left: 6px; }
.same-badge { color: #555; font-size: 0.82rem; font-style: italic; }
.status-chip {
display: inline-flex; align-items: center; justify-content: center;
@@ -95,6 +128,9 @@
.chip-dot { width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; background: currentColor; }
.empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.empty-state span { color: var(--gold-soft); }
.onboard-cta { padding: 36px 16px; text-align: center; }
.onboard-cta h3 { color: var(--gold); font-size: 1.05rem; margin-bottom: 8px; letter-spacing: 0.04em; }
.onboard-cta p { color: var(--muted); font-size: 0.88rem; margin-bottom: 16px; line-height: 1.5; }
tr.row-filtered td { opacity: 0.45; }
tr.row-filtered .name-cell::after {
@@ -163,6 +199,14 @@
table.pivot td.cell-on { color: #c9f7dc; }
table.pivot td.cell-absent { color: var(--muted); }
table.pivot th.weekend, table.pivot td.weekend { background: rgba(255,255,255,0.02); }
table.pivot th.total-col, table.pivot td.total-col {
border-left: 1px solid rgba(255,255,255,0.12);
font-variant-numeric: tabular-nums; font-size: 0.78rem;
}
table.pivot td.tot-late { color: #ffb3b5; }
table.pivot td.tot-absent { color: var(--warn); }
table.pivot tr.totals-row td { border-top: 1px solid rgba(255,255,255,0.14); font-weight: 600; color: var(--text); }
table.pivot tr.totals-row td.name-col { color: var(--gold-soft); }
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
@@ -323,7 +367,13 @@
<h1>Building Access</h1>
<div class="subtitle">Daily badge-in attendance &mdash; powered by UniFi Access</div>
</div>
<div class="header-right">
<div class="health-pill" id="health-pill" title="Webhook ingestion health">
<span class="dot" id="health-dot"></span>
<span id="health-text">checking…</span>
</div>
<div class="badge">LIVE ATTENDANCE DASHBOARD</div>
</div>
</header>
<section class="controls">
@@ -339,12 +389,24 @@
<label for="controller-filter">Controller</label>
<select id="controller-filter"><option value="">All</option></select>
</div>
<div class="control-group">
<label for="name-search">Search</label>
<input type="search" id="name-search" placeholder="Filter by name…" autocomplete="off">
</div>
<div class="control-group">
<label class="show-filtered-toggle" for="show-filtered">
<input type="checkbox" id="show-filtered"> Show filtered
</label>
</div>
<div class="control-group">
<label class="show-filtered-toggle" for="auto-refresh">
<input type="checkbox" id="auto-refresh" checked> Auto-refresh
</label>
</div>
<div class="spacer"></div>
<div class="control-group">
<button id="today-btn" class="small-btn">Today</button>
</div>
<div class="control-group">
<button id="refresh-btn">&#8635; Refresh</button>
<button class="sync-btn" id="sync-btn">&#8635; Sync Users</button>
@@ -357,9 +419,14 @@
</section>
<section class="summary-row">
<div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0</span> on time</div>
<div class="summary-pill"><div class="dot off"></div><span id="late-count">0</span> late</div>
<div class="summary-pill"><div class="dot all"></div><span id="total-count">0</span> total</div>
<button class="summary-pill filter-pill" data-status="ON TIME"><div class="dot on"></div><span id="on-time-count">0</span> on time</button>
<button class="summary-pill filter-pill" data-status="LATE"><div class="dot off"></div><span id="late-count">0</span> late</button>
<button class="summary-pill filter-pill active" data-status=""><div class="dot all"></div><span id="total-count">0</span> total</button>
<div class="spacer"></div>
<div class="summary-pill live-indicator" id="live-indicator" title="Auto-refresh status">
<span class="dot" id="live-dot"></span>
<span id="live-text"></span>
</div>
</section>
<section class="table-card">
@@ -367,12 +434,12 @@
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th class="sortable" data-sort="name">Name<span class="sort-arrow"></span></th>
<th>Source</th>
<th>First Badge In</th>
<th>Latest Badge In</th>
<th class="sortable" data-sort="first_ts">First Badge In<span class="sort-arrow"></span></th>
<th class="sortable" data-sort="latest_ts">Latest Badge In<span class="sort-arrow"></span></th>
<th>Actor ID</th>
<th class="align-center">Status</th>
<th class="align-center sortable" data-sort="status">Status<span class="sort-arrow"></span></th>
<th class="align-center">Actions</th>
</tr>
</thead>
@@ -552,6 +619,20 @@
</div>
</div>
<!-- Badge-in times modal -->
<div class="modal-overlay" id="times-modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="times-title" tabindex="-1">
<h2 id="times-title">Badge-In Times</h2>
<p id="times-subhead">Every badge-in for this person on the selected day.</p>
<div class="times-list" id="times-list">
<div class="empty-state">Loading…</div>
</div>
<div class="modal-actions">
<button class="modal-cancel" id="times-close">Close</button>
</div>
</div>
</div>
<!-- Generic confirmation modal -->
<div class="modal-overlay" id="confirm-modal">
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1">
@@ -725,21 +806,85 @@
if (e.key === 'Escape') closeTopModal();
});
async function loadControllerList() {
// ----- View state -----
let controllersCache = []; // last fetched /api/controllers
let currentRows = []; // last fetched /api/first-badge-status
let sortState = { key: 'first_ts', dir: 'asc' };
let statusFilter = ''; // '', 'ON TIME', 'LATE'
let lastLoadedAt = 0;
let loadInFlight = false;
let pendingController = ''; // controller id to select once dropdown is built
const AUTO_REFRESH_SEC = 45;
function relTime(ms) {
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 48) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
async function fetchControllers() {
try { controllersCache = await apiFetch('/api/controllers'); }
catch { controllersCache = []; }
return controllersCache;
}
function renderControllerDropdown() {
const sel = document.getElementById('controller-filter');
const prev = sel.value;
try {
const items = await apiFetch('/api/controllers');
sel.innerHTML = '<option value="">All controllers</option>' +
items.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
if (prev && items.some(c => c.id === prev)) sel.value = prev;
return items;
} catch {
return [];
controllersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
if (prev && controllersCache.some(c => c.id === prev)) sel.value = prev;
}
async function loadControllerList() {
await fetchControllers();
renderControllerDropdown();
updateHealth();
return controllersCache;
}
function updateHealth() {
const dot = document.getElementById('health-dot');
const text = document.getElementById('health-text');
if (!controllersCache.length) {
dot.className = 'dot idle'; text.textContent = 'no controllers'; return;
}
const enabled = controllersCache.filter(c => c.enabled);
const times = enabled.map(c => c.last_event_at).filter(Boolean)
.map(t => new Date(t).getTime()).filter(n => !isNaN(n));
if (!times.length) {
dot.className = 'dot idle';
text.textContent = enabled.some(c => !c.has_webhook) ? 'webhook not registered' : 'no events yet';
return;
}
const newest = Math.max(...times);
const ageMin = (Date.now() - newest) / 60000;
if (ageMin < 120) { dot.className = 'dot on'; text.textContent = `last badge ${relTime(newest)}`; }
else { dot.className = 'dot stale'; text.textContent = `no events ${relTime(newest)}`; }
}
function isViewingToday() {
return (document.getElementById('date').value || isoToday()) === isoToday();
}
function updateLiveIndicator() {
const dot = document.getElementById('live-dot');
const text = document.getElementById('live-text');
const auto = document.getElementById('auto-refresh').checked;
if (!isViewingToday()) { dot.className = 'dot idle'; text.textContent = 'viewing past day'; return; }
if (!auto) { dot.className = 'dot idle'; text.textContent = lastLoadedAt ? `paused · updated ${relTime(lastLoadedAt)}` : 'paused'; return; }
dot.className = 'dot on';
text.textContent = lastLoadedAt ? `updated ${relTime(lastLoadedAt)}` : 'live';
}
async function loadData() {
if (loadInFlight) return;
loadInFlight = true;
try {
const date = document.getElementById('date').value || isoToday();
const cutoff = document.getElementById('cutoff').value || '09:00';
const controllerId = document.getElementById('controller-filter').value;
@@ -755,21 +900,86 @@
showToast(`Could not load data: ${e.message}`, true);
return;
}
currentRows = data;
lastLoadedAt = Date.now();
renderTable();
updateLiveIndicator();
fetchControllers().then(updateHealth); // refresh ingestion health in background
} finally {
loadInFlight = false;
}
}
const tbody = document.getElementById('table-body');
tbody.innerHTML = '';
if (!data.length) {
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
['on-time-count','late-count','total-count'].forEach(id => {
document.getElementById(id).textContent = '0';
function sortRows(rows) {
const { key, dir } = sortState;
const mul = dir === 'asc' ? 1 : -1;
const val = r => {
if (key === 'name') return (r.name || '').toLowerCase();
if (key === 'status') return r.status === 'ON TIME' ? 0 : 1;
if (key === 'latest_ts') return r.latest_ts || r.first_ts || '';
return r.first_ts || '';
};
return rows.sort((a, b) => {
const va = val(a), vb = val(b);
if (va < vb) return -mul;
if (va > vb) return mul;
return (a.name || '').localeCompare(b.name || '');
});
}
function updateSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('sorted-asc', 'sorted-desc');
const arrow = th.querySelector('.sort-arrow');
if (th.dataset.sort === sortState.key) {
th.classList.add(sortState.dir === 'asc' ? 'sorted-asc' : 'sorted-desc');
if (arrow) arrow.textContent = sortState.dir === 'asc' ? '▲' : '▼';
}
});
}
function renderTable() {
const tbody = document.getElementById('table-body');
// Summary counts reflect the full day, independent of search/status filters.
let onTime = 0, late = 0;
currentRows.forEach(r => r.status === 'ON TIME' ? onTime++ : late++);
document.getElementById('on-time-count').textContent = onTime;
document.getElementById('late-count').textContent = late;
document.getElementById('total-count').textContent = onTime + late;
// First-run onboarding: nothing configured yet.
if (!controllersCache.length) {
tbody.innerHTML = `<tr class="empty-table-row"><td colspan="8">
<div class="onboard-cta">
<h3>No controllers configured</h3>
<p>Add a UniFi Access controller to start receiving badge-in events.<br>
The dashboard registers its webhook automatically.</p>
<button class="controllers-btn" id="onboard-add-btn">&#9881; Add a Controller</button>
</div></td></tr>`;
return;
}
let onTime = 0, late = 0;
if (!currentRows.length) {
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>';
updateSortIndicators();
return;
}
data.forEach((row, i) => {
const term = (document.getElementById('name-search').value || '').trim().toLowerCase();
let rows = currentRows.slice();
if (statusFilter) rows = rows.filter(r => r.status === statusFilter);
if (term) rows = rows.filter(r => (r.name || '').toLowerCase().includes(term));
rows = sortRows(rows);
if (!rows.length) {
tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No rows match the current filters.</td></tr>';
updateSortIndicators();
return;
}
tbody.innerHTML = '';
rows.forEach((row, i) => {
const tr = document.createElement('tr');
if (row.filtered) tr.classList.add('row-filtered');
@@ -806,9 +1016,19 @@
tr.appendChild(sourceTd);
const firstTd = document.createElement('td');
firstTd.className = 'time-first';
firstTd.dataset.label = 'First Badge In';
firstTd.textContent = row.first_ts || '—';
if (row.first_ts) {
firstTd.className = 'time-first clickable times-trigger';
firstTd.textContent = row.first_ts;
firstTd.title = 'View all badge-in times';
firstTd.dataset.personId = row.person_id || '';
firstTd.dataset.controllerId = row.controller_id || '';
firstTd.dataset.actorId = row.actor_id || '';
firstTd.dataset.name = row.name || '';
} else {
firstTd.className = 'time-first';
firstTd.textContent = '—';
}
tr.appendChild(firstTd);
const latestTd = document.createElement('td');
@@ -844,6 +1064,15 @@
const actionGroup = document.createElement('div');
actionGroup.className = 'row-action-group';
const timesBtn = document.createElement('button');
timesBtn.className = 'row-action-btn times-btn';
timesBtn.textContent = 'Times';
timesBtn.dataset.personId = row.person_id || '';
timesBtn.dataset.controllerId = row.controller_id || '';
timesBtn.dataset.actorId = row.actor_id || '';
timesBtn.dataset.name = row.name || '';
actionGroup.appendChild(timesBtn);
const hideBtn = document.createElement('button');
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
@@ -867,14 +1096,58 @@
tr.appendChild(actionTd);
tbody.appendChild(tr);
isOnTime ? onTime++ : late++;
});
document.getElementById('on-time-count').textContent = onTime;
document.getElementById('late-count').textContent = late;
document.getElementById('total-count').textContent = onTime + late;
updateSortIndicators();
}
// ----- Badge-in times modal -----
async function openTimesModal(subject, dateStr) {
const list = document.getElementById('times-list');
document.getElementById('times-subhead').innerHTML =
`All badge-ins for <strong>${escapeHtml(subject.name)}</strong> on <strong>${escapeHtml(dateStr)}</strong>.`;
list.innerHTML = '<div class="empty-state">Loading…</div>';
openModal('times-modal', '#times-close');
const p = new URLSearchParams({ date: dateStr });
if (subject.personId) {
p.set('person_id', subject.personId);
} else if (subject.controllerId && subject.actorId) {
p.set('controller_id', subject.controllerId);
p.set('actor_id', subject.actorId);
} else {
list.innerHTML = '<div class="empty-state">Missing identity for this row.</div>';
return;
}
let events;
try {
events = await apiFetch('/api/badge-events?' + p.toString());
} catch (e) {
list.innerHTML = `<div class="empty-state">Failed to load times: ${escapeHtml(e.message)}</div>`;
return;
}
if (!events.length) {
list.innerHTML = '<div class="empty-state">No badge-in events recorded for this day.</div>';
return;
}
const last = events.length - 1;
list.innerHTML = events.map((e, i) => {
const tags =
(i === 0 ? '<span class="t-tag">first</span>' : '') +
(i === last && events.length > 1 ? '<span class="t-tag">latest</span>' : '');
return `<div class="time-entry${i === 0 ? ' is-first' : ''}">
<span class="t-idx">${i + 1}</span>
<span class="t-time">${escapeHtml(e.ts)}${tags}</span>
<span class="source-chip">${escapeHtml(e.source)}</span>
</div>`;
}).join('');
}
document.getElementById('times-close').addEventListener('click', () => closeModal('times-modal'));
document.getElementById('times-modal').addEventListener('click', e => {
if (e.target.id === 'times-modal') closeModal('times-modal');
});
async function setActorFiltered(controllerId, actorId, filtered) {
if (!controllerId || !actorId) {
showToast('Missing controller or actor id', true);
@@ -906,6 +1179,26 @@
}
document.getElementById('table-body').addEventListener('click', async e => {
const onboardBtn = e.target.closest('#onboard-add-btn');
if (onboardBtn) {
document.getElementById('open-controllers-btn').click();
return;
}
const timesEl = e.target.closest('.times-trigger, .times-btn');
if (timesEl) {
await openTimesModal(
{
personId: timesEl.dataset.personId,
controllerId: timesEl.dataset.controllerId,
actorId: timesEl.dataset.actorId,
name: timesEl.dataset.name,
},
document.getElementById('date').value || isoToday(),
);
return;
}
const hideBtn = e.target.closest('.hide-btn');
const mergeBtn = e.target.closest('.merge-btn');
@@ -1024,7 +1317,7 @@
${escapeHtml(c.name)}
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
</div>
<div class="ctrl-sub">${escapeHtml(c.host)}:${c.port} &middot; last sync ${fmtDate(c.last_sync_at)}${c.has_webhook ? '' : ' &middot; <span style="color:var(--danger)">no webhook</span>'}</div>
<div class="ctrl-sub">${escapeHtml(c.host)}:${c.port} &middot; last sync ${fmtDate(c.last_sync_at)} &middot; last event ${c.last_event_at ? fmtDate(c.last_event_at) : '<span style="color:var(--warn)">never</span>'}${c.has_webhook ? '' : ' &middot; <span style="color:var(--danger)">no webhook</span>'}</div>
</div>
<div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="test" data-id="${c.id}">Test</button>
@@ -1605,22 +1898,43 @@
const head = '<th class="name-col">Name</th>' + data.dates.map(d => {
const label = d.slice(5); // MM-DD
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`;
}).join('');
}).join('') +
'<th class="total-col cell-on">On&nbsp;time</th>' +
'<th class="total-col cell-late">Late</th>' +
'<th class="total-col cell-absent">Absent</th>';
// Column totals accumulate as we walk each subject row.
const colTot = { on: 0, late: 0, absent: 0 };
const body = data.subjects.map(s => {
let sOn = 0, sLate = 0, sAbsent = 0;
const cells = data.dates.map(d => {
const r = byKey[s.key] && byKey[s.key][d];
const wknd = isWeekend(d) ? ' weekend' : '';
if (!r || r.status === 'ABSENT')
if (!r || r.status === 'ABSENT') {
sAbsent++;
return `<td class="cell-absent${wknd}">—</td>`;
}
if (r.status === 'LATE') sLate++; else sOn++;
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
}).join('');
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}</tr>`;
colTot.on += sOn; colTot.late += sLate; colTot.absent += sAbsent;
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}` +
`<td class="total-col">${sOn}</td>` +
`<td class="total-col tot-late">${sLate}</td>` +
`<td class="total-col tot-absent">${sAbsent}</td></tr>`;
}).join('');
const totalsRow =
`<tr class="totals-row"><td class="name-col">All (${data.subjects.length})</td>` +
data.dates.map(() => '<td></td>').join('') +
`<td class="total-col">${colTot.on}</td>` +
`<td class="total-col tot-late">${colTot.late}</td>` +
`<td class="total-col tot-absent">${colTot.absent}</td></tr>`;
result.innerHTML =
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}${totalsRow}</tbody></table>`;
}
function openReportModal() {
@@ -1666,20 +1980,122 @@
renderReportSubjects();
});
document.getElementById('report-run-btn').addEventListener('click', runReport);
document.getElementById('report-csv-btn').addEventListener('click', () => {
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
document.getElementById('report-csv-btn').addEventListener('click', async () => {
const btn = document.getElementById('report-csv-btn');
const errEl = document.getElementById('report-error');
errEl.textContent = '';
const orig = btn.innerHTML;
btn.disabled = true; btn.textContent = 'Exporting…';
try {
const res = await fetch('/api/report?' + reportParams({ format: 'csv' }).toString());
if (!res.ok) {
let detail = `Export failed (${res.status})`;
try { const j = await res.json(); detail = j.error || detail; } catch {}
throw new Error(detail);
}
const blob = await res.blob();
const cd = res.headers.get('content-disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const filename = m ? m[1] : 'badge-report.csv';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
showToast('CSV downloaded');
} catch (e) {
errEl.textContent = e.message || 'Export failed';
} finally {
btn.disabled = false; btn.innerHTML = orig;
}
});
// -------- View state persistence (localStorage + shareable URL) --------
function saveState() {
const s = {
cutoff: document.getElementById('cutoff').value,
controller: document.getElementById('controller-filter').value,
showFiltered: document.getElementById('show-filtered').checked,
autoRefresh: document.getElementById('auto-refresh').checked,
};
try { localStorage.setItem('uad_view', JSON.stringify(s)); } catch {}
const p = new URLSearchParams();
const date = document.getElementById('date').value;
if (date && date !== isoToday()) p.set('date', date);
if (s.cutoff && s.cutoff !== '09:00') p.set('cutoff', s.cutoff);
if (s.controller) p.set('controller_id', s.controller);
if (s.showFiltered) p.set('include_filtered', '1');
const qs = p.toString();
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
}
function restoreState() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem('uad_view') || '{}'); } catch {}
const url = new URLSearchParams(location.search);
document.getElementById('date').value = url.get('date') || isoToday();
document.getElementById('cutoff').value = url.get('cutoff') || saved.cutoff || '09:00';
document.getElementById('show-filtered').checked = url.get('include_filtered') === '1' || !!saved.showFiltered;
document.getElementById('auto-refresh').checked = saved.autoRefresh !== false; // default on
pendingController = url.get('controller_id') || saved.controller || '';
}
// -------- Wire up --------
document.getElementById('refresh-btn').addEventListener('click', loadData);
document.getElementById('sync-btn').addEventListener('click', syncUsers);
document.getElementById('controller-filter').addEventListener('change', loadData);
document.getElementById('show-filtered').addEventListener('change', loadData);
document.getElementById('controller-filter').addEventListener('change', () => { saveState(); loadData(); });
document.getElementById('show-filtered').addEventListener('change', () => { saveState(); loadData(); });
document.getElementById('date').addEventListener('change', () => { saveState(); loadData(); });
document.getElementById('cutoff').addEventListener('change', () => { saveState(); loadData(); });
document.getElementById('auto-refresh').addEventListener('change', () => { saveState(); updateLiveIndicator(); });
document.getElementById('name-search').addEventListener('input', renderTable);
document.getElementById('today-btn').addEventListener('click', () => {
document.getElementById('date').value = isoToday();
saveState();
loadData();
});
// Sortable column headers.
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortState.key === key) sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
else sortState = { key, dir: 'asc' };
renderTable();
});
});
// Clickable summary pills act as status filters.
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
statusFilter = pill.dataset.status;
document.querySelectorAll('.filter-pill').forEach(p => p.classList.toggle('active', p === pill));
renderTable();
});
});
// Auto-refresh ticker: keeps the "updated Xs ago" label fresh and reloads today's
// data every AUTO_REFRESH_SEC while auto-refresh is on and today is in view.
setInterval(() => {
updateLiveIndicator();
updateHealth();
if (document.getElementById('auto-refresh').checked
&& isViewingToday()
&& lastLoadedAt
&& (Date.now() - lastLoadedAt) >= AUTO_REFRESH_SEC * 1000) {
loadData();
}
}, 1000);
window.addEventListener('load', async () => {
const dateInput = document.getElementById('date');
if (!dateInput.value) dateInput.value = isoToday();
restoreState();
await loadControllerList();
const sel = document.getElementById('controller-filter');
if (pendingController && controllersCache.some(c => c.id === pendingController)) {
sel.value = pendingController;
}
await loadData();
});
</script>