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

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

Docs updated to cover all of the above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-01 23:14:03 -05:00
parent d36ba4ee73
commit 202d51753e
3 changed files with 640 additions and 128 deletions
+49 -13
View File
@@ -18,9 +18,18 @@ first-badge time per human, not per badge UUID — no more false LATE warnings
for staff who badge into multiple buildings before the cutoff. (05/28/26)
**Reporting:** export first badge-in times over a date range for one person or a
multi-selected group. View an on-screen pivot (users × dates) or download a
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles
as an attendance report. (06/19/26)
multi-selected group. View an on-screen pivot (users × dates) with per-person
on-time / late / absent totals, or download a long-format CSV. Days with no
badge-in are reported as **absent**, so it doubles as an attendance report. (06/19/26)
**Live & searchable:** the table auto-refreshes on a 45-second interval with a
live "updated Xs ago" indicator, plus a header **health pill** that shows when the
last badge event actually arrived — so a dead webhook is obvious at a glance.
Filter rows by name, click any column header to sort, and click the summary pills
to show only on-time or late people. View settings (date, cutoff, controller,
filters) persist across reloads and are encoded in the URL for bookmarking and
sharing. Click any person's badge time — or their **Times** button — to see
**every** badge-in for that day, not just first and latest. (07/01/26)
---
@@ -183,8 +192,11 @@ Per-controller actions in the modal:
| **Date picker** | Choose which day to view (defaults to the browser's local date) |
| **Badged in by** | Set your on-time cutoff (HH:MM) |
| **Controller** | Filter the table to one controller, or show All |
| **Search** | Filter the visible rows by name (client-side, instant) |
| **Show filtered** | Include filtered tenants in the table (dimmed and tagged) |
| **Refresh** | Reload the table |
| **Auto-refresh** | Toggle live 45-second polling of the current day (auto-pauses when viewing a past date) |
| **Today** | Jump the date picker back to today |
| **Refresh** | Reload the table now |
| **Sync Users** | Pull latest users from every enabled controller |
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
| **📊 Report** | Open the date-range report builder (first badge-in per day, per user, CSV export) |
@@ -196,14 +208,18 @@ Per-controller actions in the modal:
| Column | Description |
|---|---|
| **#** | Row number |
| **#** | Row number (within the current sort/filter) |
| **Name** | Resolved display name from UniFi Access |
| **Source** | Controller this badge event came from |
| **First Badge In** | Earliest door entry for the day — never changes once set |
| **First Badge In** | Earliest door entry for the day — never changes once set. **Click it** (or the Times button) to see the full list of the day's badge-ins |
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
| **Actor ID** | First 8 characters of the UniFi user UUID |
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
| **Actions** | **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
| **Actions** | **Times** opens every badge-in for that person on the selected day; **Hide** filters this person out of future views; **Merge** joins two badge identities so they count as one human |
> Click a sortable header (Name, First/Latest Badge In, Status) to sort by that
> column; click again to reverse. Click the **on time** / **late** / **total**
> summary pills to show only that group.
> Once two identities are merged, the Source column shows a chip for every
> controller the person badged into that day, plus a "MERGED" pill so it's
@@ -211,6 +227,23 @@ Per-controller actions in the modal:
### Interface notes
- **Auto-refresh & live status.** When viewing today with Auto-refresh on, the
table reloads every 45 seconds. The pill in the summary row shows *"updated Xs
ago"* (or *"paused"* / *"viewing past day"* when polling is off).
- **Ingestion health.** The pill in the header shows the freshest badge event
across all enabled controllers — *"last badge 3m ago"* (green), *"no events…"*
(amber, stale), or *"webhook not registered"* — so a broken webhook is caught
without digging into the Controllers modal (which also shows per-controller
last-event time).
- **Badge-in times.** Clicking a person's First-badge time or their **Times**
button lists every badge-in for that day, oldest first, tagged *first* and
*latest* and labelled with the source controller. For merged people the list
spans all their identities.
- **View persistence & sharing.** Date, cutoff, controller, and the filter
toggles are saved to the browser and mirrored into the URL, so reloading keeps
your view and you can bookmark or share a link to a specific day/controller.
- **First-run onboarding.** With no controllers configured, the table shows an
"Add a Controller" call-to-action instead of an empty grid.
- On narrow screens, the attendance table automatically changes into stacked
row cards with labels for each field, so all columns remain visible without
sideways scrolling.
@@ -289,7 +322,8 @@ In the report modal:
- **Run Report** renders an on-screen **pivot**: one row per selected user, one
column per date, each cell showing that day's first badge-in time
(green = on time, red = late). Weekends are shaded and days with no badge-in
show as **—**.
show as **—**. Trailing **On time / Late / Absent** columns total each person
over the range, and a final **All** row totals every column.
- **Export CSV** downloads the same data in **long format** — one row per
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
Excel or Sheets.
@@ -313,10 +347,11 @@ git pull
/usr/bin/docker compose up -d --build
```
The SQLite database in `./data/` persists across rebuilds. On first start after
upgrading from a single-controller install, existing badge events are
automatically attached to the seeded "Default" controller — nothing to migrate
by hand.
The SQLite database in `./data/` persists across rebuilds. Schema changes (such
as the `last_event_at` column added for ingestion health) are applied
automatically on boot. On first start after upgrading from a single-controller
install, existing badge events are automatically attached to the seeded
"Default" controller — nothing to migrate by hand.
---
@@ -331,6 +366,7 @@ reverse proxy with auth in front of it.
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?`, `include_filtered?` | Returns first + latest badge per user (filtered tenants hidden unless `include_filtered=1`) |
| `GET` | `/api/badge-events` | `date`, and either `person_id` **or** `controller_id`+`actor_id` | Every badge-in for one subject on one day, oldest first (for a merged person, across all their identities) |
| `GET` | `/api/report/subjects` | `controller_id?`, `include_filtered?` | List selectable report subjects (merged persons + unmerged actors), each with a stable `key` |
| `GET` | `/api/report` | `start`, `end`, `cutoff?`, `subjects?`, `controller_id?`, `include_filtered?`, `format?` | First badge-in per subject per day over the range. JSON (pivot-shaped) by default; `format=csv` returns a long-format download. Missing days reported as `ABSENT`. `subjects` is a comma-separated list of subject `key`s (empty = all) |
| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag |
@@ -342,7 +378,7 @@ reverse proxy with auth in front of it.
| `POST` | `/api/persons/<id>/members` | `controller_id`, `actor_id` | Add another identity to an existing person |
| `DELETE` | `/api/persons/<id>/members/<cid>/<aid>` | — | Split one identity off; dissolves the person if it was the last member |
| `GET` | `/api/persons/suggestions` | — | Exact full-name matches across controllers, excluding already-merged actors |
| `GET` | `/api/controllers` | — | List configured controllers |
| `GET` | `/api/controllers` | — | List configured controllers (includes `last_sync_at` and `last_event_at` for health) |
| `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) |
| `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |