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
+530 -114
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="badge">LIVE ATTENDANCE DASHBOARD</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,53 +806,182 @@
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 [];
sel.innerHTML = '<option value="">All controllers</option>' +
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 loadData() {
const date = document.getElementById('date').value || isoToday();
const cutoff = document.getElementById('cutoff').value || '09:00';
const controllerId = document.getElementById('controller-filter').value;
const showFiltered = document.getElementById('show-filtered').checked;
const params = new URLSearchParams({ date, cutoff });
if (controllerId) params.set('controller_id', controllerId);
if (showFiltered) params.set('include_filtered', '1');
async function loadControllerList() {
await fetchControllers();
renderControllerDropdown();
updateHealth();
return controllersCache;
}
let data;
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 {
data = await apiFetch('/api/first-badge-status?' + params.toString());
} catch (e) {
showToast(`Could not load data: ${e.message}`, true);
const date = document.getElementById('date').value || isoToday();
const cutoff = document.getElementById('cutoff').value || '09:00';
const controllerId = document.getElementById('controller-filter').value;
const showFiltered = document.getElementById('show-filtered').checked;
const params = new URLSearchParams({ date, cutoff });
if (controllerId) params.set('controller_id', controllerId);
if (showFiltered) params.set('include_filtered', '1');
let data;
try {
data = await apiFetch('/api/first-badge-status?' + params.toString());
} catch (e) {
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;
}
}
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;
}
const tbody = document.getElementById('table-body');
tbody.innerHTML = '';
if (!data.length) {
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>';
['on-time-count','late-count','total-count'].forEach(id => {
document.getElementById(id).textContent = '0';
});
return;
}
updateSortIndicators();
return;
}
let onTime = 0, late = 0;
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);
data.forEach((row, i) => {
const tr = document.createElement('tr');
if (row.filtered) tr.classList.add('row-filtered');
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');
const numTd = document.createElement('td');
numTd.className = 'muted-cell';
@@ -788,39 +998,49 @@
const sourceTd = document.createElement('td');
sourceTd.dataset.label = 'Source';
const sourceWrap = document.createElement('span');
sourceWrap.className = 'source-list';
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
sources.forEach(s => {
const chip = document.createElement('span');
chip.className = 'source-chip';
chip.textContent = s;
sourceWrap.appendChild(chip);
});
sourceTd.appendChild(sourceWrap);
if (row.merged) {
const pill = document.createElement('span');
pill.className = 'merged-pill';
pill.textContent = 'MERGED';
sourceTd.appendChild(pill);
}
tr.appendChild(sourceTd);
sourceWrap.className = 'source-list';
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
sources.forEach(s => {
const chip = document.createElement('span');
chip.className = 'source-chip';
chip.textContent = s;
sourceWrap.appendChild(chip);
});
sourceTd.appendChild(sourceWrap);
if (row.merged) {
const pill = document.createElement('span');
pill.className = 'merged-pill';
pill.textContent = 'MERGED';
sourceTd.appendChild(pill);
}
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');
latestTd.dataset.label = 'Latest Badge In';
if (!row.latest_ts) {
latestTd.className = 'same-badge';
latestTd.textContent = '— same';
} else {
latestTd.className = 'time-latest';
latestTd.textContent = row.latest_ts;
}
tr.appendChild(latestTd);
latestTd.className = 'same-badge';
latestTd.textContent = '— same';
} else {
latestTd.className = 'time-latest';
latestTd.textContent = row.latest_ts;
}
tr.appendChild(latestTd);
const idTd = document.createElement('td');
idTd.className = 'muted-cell';
@@ -831,50 +1051,103 @@
const statusTd = document.createElement('td');
statusTd.className = 'align-center';
statusTd.dataset.label = 'Status';
const statusChip = document.createElement('div');
const isOnTime = row.status === 'ON TIME';
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(statusChip);
tr.appendChild(statusTd);
const statusChip = document.createElement('div');
const isOnTime = row.status === 'ON TIME';
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(statusChip);
tr.appendChild(statusTd);
const actionTd = document.createElement('td');
actionTd.className = 'align-center';
actionTd.dataset.label = 'Actions';
const actionGroup = document.createElement('div');
actionGroup.className = 'row-action-group';
const actionGroup = document.createElement('div');
actionGroup.className = 'row-action-group';
const hideBtn = document.createElement('button');
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
hideBtn.dataset.personId = row.person_id || '';
hideBtn.dataset.controllerId = row.controller_id || '';
hideBtn.dataset.actorId = row.actor_id || '';
hideBtn.dataset.filtered = row.filtered ? '1' : '0';
hideBtn.dataset.name = row.name || '';
actionGroup.appendChild(hideBtn);
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 mergeBtn = document.createElement('button');
mergeBtn.className = 'row-action-btn merge merge-btn';
mergeBtn.textContent = row.merged ? 'Manage' : 'Merge';
mergeBtn.dataset.personId = row.person_id || '';
mergeBtn.dataset.controllerId = row.controller_id || '';
mergeBtn.dataset.actorId = row.actor_id || '';
mergeBtn.dataset.name = row.name || '';
actionGroup.appendChild(mergeBtn);
const hideBtn = document.createElement('button');
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
hideBtn.dataset.personId = row.person_id || '';
hideBtn.dataset.controllerId = row.controller_id || '';
hideBtn.dataset.actorId = row.actor_id || '';
hideBtn.dataset.filtered = row.filtered ? '1' : '0';
hideBtn.dataset.name = row.name || '';
actionGroup.appendChild(hideBtn);
actionTd.appendChild(actionGroup);
tr.appendChild(actionTd);
const mergeBtn = document.createElement('button');
mergeBtn.className = 'row-action-btn merge merge-btn';
mergeBtn.textContent = row.merged ? 'Manage' : 'Merge';
mergeBtn.dataset.personId = row.person_id || '';
mergeBtn.dataset.controllerId = row.controller_id || '';
mergeBtn.dataset.actorId = row.actor_id || '';
mergeBtn.dataset.name = row.name || '';
actionGroup.appendChild(mergeBtn);
tbody.appendChild(tr);
isOnTime ? onTime++ : late++;
actionTd.appendChild(actionGroup);
tr.appendChild(actionTd);
tbody.appendChild(tr);
});
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');
});
document.getElementById('on-time-count').textContent = onTime;
document.getElementById('late-count').textContent = late;
document.getElementById('total-count').textContent = onTime + late;
}
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>