feat(portal): M1 pages — locations overview + read-only live location view

/portal overview: client's active sound locations as live tiles (current Lp +
Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s)
plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated
read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace
(backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no
device controls, no refresh-from-device. _client_locations() feeds the overview.

Verified: portal.py compiles; both inline scripts balance; all four portal
templates parse in Jinja2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:41:13 +00:00
parent 9f40210057
commit d3e221b6b1
3 changed files with 257 additions and 8 deletions
+138
View File
@@ -0,0 +1,138 @@
{% extends "portal/base.html" %}
{% block title %}{{ location.name }}{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
{% endblock %}
{% block content %}
<a href="/portal" class="text-sm text-gray-400 hover:text-gray-200">&larr; All locations</a>
<h1 class="text-2xl font-semibold mt-1">{{ location.name }}</h1>
<div class="flex items-center gap-2 text-sm mt-1 mb-6">
<span id="p-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
<span id="p-fresh" class="text-gray-400"></span>
</div>
{% if not has_device %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
No device is currently assigned to this location.
</div>
{% else %}
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Lp (Instant)</div>
<div id="p-lp" class="text-2xl font-bold text-blue-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Leq (Average)</div>
<div id="p-leq" class="text-2xl font-bold text-green-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">Lmax (Max)</div>
<div id="p-lmax" class="text-2xl font-bold text-red-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">L1</div>
<div id="p-ln1" class="text-2xl font-bold text-purple-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
<div class="text-xs text-gray-400">L10</div>
<div id="p-ln2" class="text-2xl font-bold text-orange-400">--</div><div class="text-xs text-gray-500">dB</div>
</div>
</div>
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
<canvas id="p-chart"></canvas>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{% if has_device %}
<script>
const LOC_ID = "{{ location.id }}";
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
let chart;
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
function ds(label, color) {
return { label, data: [], borderColor: color, backgroundColor: color,
borderWidth: 2, pointRadius: 0, tension: 0.3, spanGaps: true };
}
function initChart() {
const ctx = document.getElementById('p-chart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [
ds('Lp', 'rgb(96,165,250)'), ds('Leq', 'rgb(74,222,128)'),
ds('L1', 'rgb(192,132,252)'), ds('L10', 'rgb(251,146,60)') ] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { intersect: false, mode: 'index' },
scales: {
y: { min: 30, max: 130, title: { display: true, text: 'dB', color: '#94a3b8' },
ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
x: { ticks: { color: '#94a3b8', maxTicksLimit: 8 }, grid: { color: 'rgba(148,163,184,0.12)' } }
},
plugins: { legend: { labels: { color: '#cbd5e1' } } }
}
});
}
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
function setBadge(measuring, lastSeen) {
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
if (measuring === null) { b.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full'; b.textContent = ''; }
else if (measuring) { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/40 text-green-300'; b.textContent = '● Live'; }
else { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-slate-700 text-gray-300'; b.textContent = '■ Stopped'; }
f.innerHTML = fmtFreshness(lastSeen);
}
function fmtFreshness(iso) {
if (!iso) return '<span class="text-gray-500">no recent reading</span>';
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
let ago, stale = false;
if (s < 10) ago = 'just now';
else if (s < 60) ago = s + 's ago';
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
const cls = stale ? 'text-amber-400' : 'text-gray-400';
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
}
async function prefill() {
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
const d = j.data;
if (!d) {
setBadge(null, null);
document.getElementById('p-fresh').textContent =
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
return;
}
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
setBadge(measuring, d.last_seen);
} catch (e) { /* keep last values */ }
}
async function backfill() {
try {
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
for (const row of (j.readings || [])) {
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
}
chart.data.labels = cd.t;
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
chart.update('none');
} catch (e) { /* leave chart empty */ }
}
initChart();
prefill();
backfill();
setInterval(prefill, 15000); // cache poll — read-only, no device contention
</script>
{% endif %}
{% endblock %}