feat: live status chip on NRL list cards

Add a compact live-status chip to each NRL card in the location list, so the
inline list next to the map (Overview tab) and the Sound > NRLs tab show live
state alongside the new live tiles. Both surfaces share location_list.html, so
this lands in both.

- Chip shows "● <Leq> dB" when measuring (tinted green/amber/red at the same
  55/70 thresholds as the live tiles), else Stopped / Offline / Wedged.
  Hidden for cards with no assigned unit and for vibration locations.
- Painted by the existing 15s Overview poller (no extra requests). Repaints on
  htmx:afterSwap so the chips survive the NRL list reloading (e.g. the 30s
  dashboard swap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:30:54 +00:00
parent 76330f6137
commit bb5b407c98
2 changed files with 52 additions and 2 deletions
@@ -78,8 +78,12 @@
</div>
</div>
<!-- Right column: small assign/unassign pill + 3-dot menu -->
<!-- Right column: live status chip + small assign/unassign pill + 3-dot menu -->
<div class="flex items-center gap-2 shrink-0">
<!-- Live status chip — painted by the Overview live poller
(paintInlineLive) keyed on data-loc-live. Hidden until
there's live data for this location. -->
<span class="nrl-live-chip hidden" data-loc-live="{{ item.location.id }}"></span>
{% if not item.assignment %}
<!-- Primary action: visible because the unassigned card
is most likely getting clicked on right after creation -->
+47 -1
View File
@@ -2233,6 +2233,43 @@ function lsRender(locations) {
document.getElementById('ls-tiles').innerHTML = locations.map(lsRenderTile).join('');
}
// Compact level-tinted pill classes for the inline NRL-card chips.
function lsInlineLevelPill(leq) {
if (leq == null) return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
if (leq >= LS_LEVEL_RED) return 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-300';
if (leq >= LS_LEVEL_AMBER) return 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300';
return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
}
function lsInlineChipHtml(loc) {
if (!loc.unit_id) return ''; // no unit assigned → no chip
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ';
const measuring = loc.measurement_state === 'Start' || loc.measurement_state === 'Measure';
const hasData = loc.measurement_state != null || loc.leq != null;
const reachable = loc.is_reachable !== false;
if (loc.connection_state === 'wedged')
return `<span class="${base}bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-300">Wedged</span>`;
if (!reachable || !hasData)
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Offline</span>`;
if (measuring) {
const leqStr = (loc.leq == null || loc.leq === '') ? '--' : loc.leq;
return `<span class="${base}${lsInlineLevelPill(lsNum(loc.leq))}">`
+ `<span class="w-1.5 h-1.5 rounded-full bg-current"></span>${lsEsc(leqStr)} dB</span>`;
}
return `<span class="${base}bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Stopped</span>`;
}
// Paint the inline chips on the NRL list cards (Overview + Sound tab).
function lsPaintInline(locations) {
const byId = {};
for (const l of locations) byId[l.id] = l;
document.querySelectorAll('[data-loc-live]').forEach(el => {
const loc = byId[el.getAttribute('data-loc-live')];
const html = loc ? lsInlineChipHtml(loc) : '';
el.innerHTML = html;
el.classList.toggle('hidden', !html);
});
}
let lsLastData = [];
async function loadLiveStats() {
// Skip work while the tab is hidden in the background.
if (document.hidden) return;
@@ -2240,10 +2277,19 @@ async function loadLiveStats() {
const r = await fetch(`/api/projects/${projectId}/live-stats`);
if (!r.ok) return;
const j = await r.json();
lsRender(j.locations || []);
lsLastData = j.locations || [];
lsRender(lsLastData);
lsPaintInline(lsLastData);
} catch (e) { /* keep last render */ }
}
// The NRL list partial reloads via htmx (e.g. the 30s dashboard swap), which
// wipes the painted chips — repaint from the last poll as soon as it settles.
document.body.addEventListener('htmx:afterSwap', (e) => {
const id = e.target && e.target.id;
if (id === 'project-locations' || id === 'sound-locations') lsPaintInline(lsLastData);
});
function startLiveStats() {
if (liveStatsTimer) return; // already running
loadLiveStats();