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) 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 **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 multi-selected group. View an on-screen pivot (users × dates) with per-person
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles on-time / late / absent totals, or download a long-format CSV. Days with no
as an attendance report. (06/19/26) 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) | | **Date picker** | Choose which day to view (defaults to the browser's local date) |
| **Badged in by** | Set your on-time cutoff (HH:MM) | | **Badged in by** | Set your on-time cutoff (HH:MM) |
| **Controller** | Filter the table to one controller, or show All | | **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) | | **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 | | **Sync Users** | Pull latest users from every enabled controller |
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide | | **🚫 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) | | **📊 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 | | Column | Description |
|---|---| |---|---|
| **#** | Row number | | **#** | Row number (within the current sort/filter) |
| **Name** | Resolved display name from UniFi Access | | **Name** | Resolved display name from UniFi Access |
| **Source** | Controller this badge event came from | | **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 | | **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
| **Actor ID** | First 8 characters of the UniFi user UUID | | **Actor ID** | First 8 characters of the UniFi user UUID |
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff | | **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 > 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 > 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 ### 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 - On narrow screens, the attendance table automatically changes into stacked
row cards with labels for each field, so all columns remain visible without row cards with labels for each field, so all columns remain visible without
sideways scrolling. sideways scrolling.
@@ -289,7 +322,8 @@ In the report modal:
- **Run Report** renders an on-screen **pivot**: one row per selected user, one - **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 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 (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 - **Export CSV** downloads the same data in **long format** — one row per
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
Excel or Sheets. Excel or Sheets.
@@ -313,10 +347,11 @@ git pull
/usr/bin/docker compose up -d --build /usr/bin/docker compose up -d --build
``` ```
The SQLite database in `./data/` persists across rebuilds. On first start after The SQLite database in `./data/` persists across rebuilds. Schema changes (such
upgrading from a single-controller install, existing badge events are as the `last_event_at` column added for ingestion health) are applied
automatically attached to the seeded "Default" controller — nothing to migrate automatically on boot. On first start after upgrading from a single-controller
by hand. 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/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest 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/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/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/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 | | `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 | | `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 | | `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/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) | | `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 | | `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) | | `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 '', webhook_id TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, 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" "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( db.execute(
""" """
CREATE TABLE IF NOT EXISTS persons ( CREATE TABLE IF NOT EXISTS persons (
@@ -314,6 +319,7 @@ def controller_to_dict(row):
"enabled": bool(row["enabled"]), "enabled": bool(row["enabled"]),
"has_webhook": bool(row["webhook_id"]), "has_webhook": bool(row["webhook_id"]),
"last_sync_at": row["last_sync_at"], "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) 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 # 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 (?, ?, ?, ?)", "INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
(controller_id, actor, ts_str, date), (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() db.commit()
log.info( log.info(
+530 -114
View File
@@ -25,6 +25,12 @@
padding: 24px 24px 32px; padding: 24px 24px 32px;
} }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; } 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; } .title-block { display: flex; flex-direction: column; gap: 4px; }
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); } h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
.subtitle { font-size: 0.9rem; color: var(--muted); } .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)); } .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)); } .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; } .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); } .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 { width: 9px; height: 9px; border-radius: 50%; }
.dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); } .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.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.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 { .table-card {
background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98)); 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; 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)); } 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, 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 { 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:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(212,175,55,0.04); } tbody tr:hover { background: rgba(212,175,55,0.04); }
.name-cell { font-weight: 500; color: var(--text); } .name-cell { font-weight: 500; color: var(--text); }
@@ -83,7 +103,20 @@
} }
.align-center { text-align: center; } .align-center { text-align: center; }
.time-first { color: var(--text); font-weight: 500; } .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; } .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; } .same-badge { color: #555; font-size: 0.82rem; font-style: italic; }
.status-chip { .status-chip {
display: inline-flex; align-items: center; justify-content: center; 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; } .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 { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.empty-state span { color: var(--gold-soft); } .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 td { opacity: 0.45; }
tr.row-filtered .name-cell::after { tr.row-filtered .name-cell::after {
@@ -163,6 +199,14 @@
table.pivot td.cell-on { color: #c9f7dc; } table.pivot td.cell-on { color: #c9f7dc; }
table.pivot td.cell-absent { color: var(--muted); } 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.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; } .report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; } .source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
@@ -323,7 +367,13 @@
<h1>Building Access</h1> <h1>Building Access</h1>
<div class="subtitle">Daily badge-in attendance &mdash; powered by UniFi Access</div> <div class="subtitle">Daily badge-in attendance &mdash; powered by UniFi Access</div>
</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> </header>
<section class="controls"> <section class="controls">
@@ -339,12 +389,24 @@
<label for="controller-filter">Controller</label> <label for="controller-filter">Controller</label>
<select id="controller-filter"><option value="">All</option></select> <select id="controller-filter"><option value="">All</option></select>
</div> </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"> <div class="control-group">
<label class="show-filtered-toggle" for="show-filtered"> <label class="show-filtered-toggle" for="show-filtered">
<input type="checkbox" id="show-filtered"> Show filtered <input type="checkbox" id="show-filtered"> Show filtered
</label> </label>
</div> </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="spacer"></div>
<div class="control-group">
<button id="today-btn" class="small-btn">Today</button>
</div>
<div class="control-group"> <div class="control-group">
<button id="refresh-btn">&#8635; Refresh</button> <button id="refresh-btn">&#8635; Refresh</button>
<button class="sync-btn" id="sync-btn">&#8635; Sync Users</button> <button class="sync-btn" id="sync-btn">&#8635; Sync Users</button>
@@ -357,9 +419,14 @@
</section> </section>
<section class="summary-row"> <section class="summary-row">
<div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0</span> on time</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>
<div class="summary-pill"><div class="dot off"></div><span id="late-count">0</span> late</div> <button class="summary-pill filter-pill" data-status="LATE"><div class="dot off"></div><span id="late-count">0</span> late</button>
<div class="summary-pill"><div class="dot all"></div><span id="total-count">0</span> total</div> <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>
<section class="table-card"> <section class="table-card">
@@ -367,12 +434,12 @@
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Name</th> <th class="sortable" data-sort="name">Name<span class="sort-arrow"></span></th>
<th>Source</th> <th>Source</th>
<th>First Badge In</th> <th class="sortable" data-sort="first_ts">First Badge In<span class="sort-arrow"></span></th>
<th>Latest Badge In</th> <th class="sortable" data-sort="latest_ts">Latest Badge In<span class="sort-arrow"></span></th>
<th>Actor ID</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> <th class="align-center">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -552,6 +619,20 @@
</div> </div>
</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 --> <!-- Generic confirmation modal -->
<div class="modal-overlay" id="confirm-modal"> <div class="modal-overlay" id="confirm-modal">
<div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1"> <div class="modal danger" role="dialog" aria-modal="true" aria-labelledby="confirm-title" tabindex="-1">
@@ -725,53 +806,182 @@
if (e.key === 'Escape') closeTopModal(); 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 sel = document.getElementById('controller-filter');
const prev = sel.value; const prev = sel.value;
try { sel.innerHTML = '<option value="">All controllers</option>' +
const items = await apiFetch('/api/controllers'); controllersCache.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
sel.innerHTML = '<option value="">All controllers</option>' + if (prev && controllersCache.some(c => c.id === prev)) sel.value = prev;
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 [];
} }
}
async function loadData() { async function loadControllerList() {
const date = document.getElementById('date').value || isoToday(); await fetchControllers();
const cutoff = document.getElementById('cutoff').value || '09:00'; renderControllerDropdown();
const controllerId = document.getElementById('controller-filter').value; updateHealth();
const showFiltered = document.getElementById('show-filtered').checked; return controllersCache;
const params = new URLSearchParams({ date, cutoff }); }
if (controllerId) params.set('controller_id', controllerId);
if (showFiltered) params.set('include_filtered', '1');
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 { try {
data = await apiFetch('/api/first-badge-status?' + params.toString()); const date = document.getElementById('date').value || isoToday();
} catch (e) { const cutoff = document.getElementById('cutoff').value || '09:00';
showToast(`Could not load data: ${e.message}`, true); 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; return;
} }
const tbody = document.getElementById('table-body'); if (!currentRows.length) {
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>'; 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 => { updateSortIndicators();
document.getElementById(id).textContent = '0'; return;
}); }
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) => { if (!rows.length) {
const tr = document.createElement('tr'); tbody.innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No rows match the current filters.</td></tr>';
if (row.filtered) tr.classList.add('row-filtered'); 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'); const numTd = document.createElement('td');
numTd.className = 'muted-cell'; numTd.className = 'muted-cell';
@@ -788,39 +998,49 @@
const sourceTd = document.createElement('td'); const sourceTd = document.createElement('td');
sourceTd.dataset.label = 'Source'; sourceTd.dataset.label = 'Source';
const sourceWrap = document.createElement('span'); const sourceWrap = document.createElement('span');
sourceWrap.className = 'source-list'; sourceWrap.className = 'source-list';
const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—']; const sources = (row.sources && row.sources.length) ? row.sources : [row.source || '—'];
sources.forEach(s => { sources.forEach(s => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'source-chip'; chip.className = 'source-chip';
chip.textContent = s; chip.textContent = s;
sourceWrap.appendChild(chip); sourceWrap.appendChild(chip);
}); });
sourceTd.appendChild(sourceWrap); sourceTd.appendChild(sourceWrap);
if (row.merged) { if (row.merged) {
const pill = document.createElement('span'); const pill = document.createElement('span');
pill.className = 'merged-pill'; pill.className = 'merged-pill';
pill.textContent = 'MERGED'; pill.textContent = 'MERGED';
sourceTd.appendChild(pill); sourceTd.appendChild(pill);
} }
tr.appendChild(sourceTd); tr.appendChild(sourceTd);
const firstTd = document.createElement('td'); const firstTd = document.createElement('td');
firstTd.className = 'time-first';
firstTd.dataset.label = 'First Badge In'; 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); tr.appendChild(firstTd);
const latestTd = document.createElement('td'); const latestTd = document.createElement('td');
latestTd.dataset.label = 'Latest Badge In'; latestTd.dataset.label = 'Latest Badge In';
if (!row.latest_ts) { if (!row.latest_ts) {
latestTd.className = 'same-badge'; latestTd.className = 'same-badge';
latestTd.textContent = '— same'; latestTd.textContent = '— same';
} else { } else {
latestTd.className = 'time-latest'; latestTd.className = 'time-latest';
latestTd.textContent = row.latest_ts; latestTd.textContent = row.latest_ts;
} }
tr.appendChild(latestTd); tr.appendChild(latestTd);
const idTd = document.createElement('td'); const idTd = document.createElement('td');
idTd.className = 'muted-cell'; idTd.className = 'muted-cell';
@@ -831,50 +1051,103 @@
const statusTd = document.createElement('td'); const statusTd = document.createElement('td');
statusTd.className = 'align-center'; statusTd.className = 'align-center';
statusTd.dataset.label = 'Status'; statusTd.dataset.label = 'Status';
const statusChip = document.createElement('div'); const statusChip = document.createElement('div');
const isOnTime = row.status === 'ON TIME'; const isOnTime = row.status === 'ON TIME';
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off'); statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE'); statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(statusChip); statusTd.appendChild(statusChip);
tr.appendChild(statusTd); tr.appendChild(statusTd);
const actionTd = document.createElement('td'); const actionTd = document.createElement('td');
actionTd.className = 'align-center'; actionTd.className = 'align-center';
actionTd.dataset.label = 'Actions'; actionTd.dataset.label = 'Actions';
const actionGroup = document.createElement('div'); const actionGroup = document.createElement('div');
actionGroup.className = 'row-action-group'; actionGroup.className = 'row-action-group';
const hideBtn = document.createElement('button'); const timesBtn = document.createElement('button');
hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : ''); timesBtn.className = 'row-action-btn times-btn';
hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide'; timesBtn.textContent = 'Times';
hideBtn.dataset.personId = row.person_id || ''; timesBtn.dataset.personId = row.person_id || '';
hideBtn.dataset.controllerId = row.controller_id || ''; timesBtn.dataset.controllerId = row.controller_id || '';
hideBtn.dataset.actorId = row.actor_id || ''; timesBtn.dataset.actorId = row.actor_id || '';
hideBtn.dataset.filtered = row.filtered ? '1' : '0'; timesBtn.dataset.name = row.name || '';
hideBtn.dataset.name = row.name || ''; actionGroup.appendChild(timesBtn);
actionGroup.appendChild(hideBtn);
const mergeBtn = document.createElement('button'); const hideBtn = document.createElement('button');
mergeBtn.className = 'row-action-btn merge merge-btn'; hideBtn.className = 'row-action-btn hide-btn' + (row.filtered ? ' unhide' : '');
mergeBtn.textContent = row.merged ? 'Manage' : 'Merge'; hideBtn.textContent = row.filtered ? 'Unhide' : 'Hide';
mergeBtn.dataset.personId = row.person_id || ''; hideBtn.dataset.personId = row.person_id || '';
mergeBtn.dataset.controllerId = row.controller_id || ''; hideBtn.dataset.controllerId = row.controller_id || '';
mergeBtn.dataset.actorId = row.actor_id || ''; hideBtn.dataset.actorId = row.actor_id || '';
mergeBtn.dataset.name = row.name || ''; hideBtn.dataset.filtered = row.filtered ? '1' : '0';
actionGroup.appendChild(mergeBtn); hideBtn.dataset.name = row.name || '';
actionGroup.appendChild(hideBtn);
actionTd.appendChild(actionGroup); const mergeBtn = document.createElement('button');
tr.appendChild(actionTd); 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); actionTd.appendChild(actionGroup);
isOnTime ? onTime++ : late++; 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) { async function setActorFiltered(controllerId, actorId, filtered) {
if (!controllerId || !actorId) { if (!controllerId || !actorId) {
showToast('Missing controller or actor id', true); showToast('Missing controller or actor id', true);
@@ -906,6 +1179,26 @@
} }
document.getElementById('table-body').addEventListener('click', async e => { 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 hideBtn = e.target.closest('.hide-btn');
const mergeBtn = e.target.closest('.merge-btn'); const mergeBtn = e.target.closest('.merge-btn');
@@ -1024,7 +1317,7 @@
${escapeHtml(c.name)} ${escapeHtml(c.name)}
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'} ${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
</div> </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>
<div class="ctrl-actions"> <div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="test" data-id="${c.id}">Test</button> <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 head = '<th class="name-col">Name</th>' + data.dates.map(d => {
const label = d.slice(5); // MM-DD const label = d.slice(5); // MM-DD
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`; 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 => { const body = data.subjects.map(s => {
let sOn = 0, sLate = 0, sAbsent = 0;
const cells = data.dates.map(d => { const cells = data.dates.map(d => {
const r = byKey[s.key] && byKey[s.key][d]; const r = byKey[s.key] && byKey[s.key][d];
const wknd = isWeekend(d) ? ' weekend' : ''; const wknd = isWeekend(d) ? ' weekend' : '';
if (!r || r.status === 'ABSENT') if (!r || r.status === 'ABSENT') {
sAbsent++;
return `<td class="cell-absent${wknd}">—</td>`; return `<td class="cell-absent${wknd}">—</td>`;
}
if (r.status === 'LATE') sLate++; else sOn++;
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on'; const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`; return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
}).join(''); }).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(''); }).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 = 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() { function openReportModal() {
@@ -1666,20 +1980,122 @@
renderReportSubjects(); renderReportSubjects();
}); });
document.getElementById('report-run-btn').addEventListener('click', runReport); document.getElementById('report-run-btn').addEventListener('click', runReport);
document.getElementById('report-csv-btn').addEventListener('click', () => { document.getElementById('report-csv-btn').addEventListener('click', async () => {
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString(); 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 -------- // -------- Wire up --------
document.getElementById('refresh-btn').addEventListener('click', loadData); document.getElementById('refresh-btn').addEventListener('click', loadData);
document.getElementById('sync-btn').addEventListener('click', syncUsers); document.getElementById('sync-btn').addEventListener('click', syncUsers);
document.getElementById('controller-filter').addEventListener('change', loadData); document.getElementById('controller-filter').addEventListener('change', () => { saveState(); loadData(); });
document.getElementById('show-filtered').addEventListener('change', 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 () => { window.addEventListener('load', async () => {
const dateInput = document.getElementById('date'); restoreState();
if (!dateInput.value) dateInput.value = isoToday();
await loadControllerList(); await loadControllerList();
const sel = document.getElementById('controller-filter');
if (pendingController && controllersCache.some(c => c.id === pendingController)) {
sel.value = pendingController;
}
await loadData(); await loadData();
}); });
</script> </script>