diff --git a/README.md b/README.md index d34c250..543156d 100644 --- a/README.md +++ b/README.md @@ -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/` | 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//members` | `controller_id`, `actor_id` | Add another identity to an existing person | | `DELETE` | `/api/persons//members//` | — | 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/` | `name?`, `enabled?` | Rename or enable/disable a controller | | `DELETE` | `/api/controllers/` | — | Remove a controller (deletes webhook + its events) | diff --git a/app.py b/app.py index 0b97d09..9b91b58 100644 --- a/app.py +++ b/app.py @@ -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( diff --git a/static/index.html b/static/index.html index ef3e744..5e90eb7 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@

Building Access

Daily badge-in attendance — powered by UniFi Access
-
LIVE ATTENDANCE DASHBOARD
+
+
+ + checking… +
+
LIVE ATTENDANCE DASHBOARD
+
@@ -339,12 +389,24 @@ +
+ + +
+
+ +
+
+ +
@@ -357,9 +419,14 @@
-
0 on time
-
0 late
-
0 total
+ + + +
+
+ + +
@@ -367,12 +434,12 @@ # - Name + Name Source - First Badge In - Latest Badge In + First Badge In + Latest Badge In Actor ID - Status + Status Actions @@ -552,6 +619,20 @@ + + +
@@ -1605,22 +1898,43 @@ const head = 'Name' + data.dates.map(d => { const label = d.slice(5); // MM-DD return `${label}`; - }).join(''); + }).join('') + + 'On time' + + 'Late' + + 'Absent'; + + // 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 `—`; + } + if (r.status === 'LATE') sLate++; else sOn++; const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on'; return `${escapeHtml(r.first_ts)}`; }).join(''); - return `${escapeHtml(s.name)}${cells}`; + colTot.on += sOn; colTot.late += sLate; colTot.absent += sAbsent; + return `${escapeHtml(s.name)}${cells}` + + `${sOn}` + + `${sLate}` + + `${sAbsent}`; }).join(''); + const totalsRow = + `All (${data.subjects.length})` + + data.dates.map(() => '').join('') + + `${colTot.on}` + + `${colTot.late}` + + `${colTot.absent}`; + result.innerHTML = - `${head}${body}
`; + `${head}${body}${totalsRow}
`; } 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(); });