Dashboard coverage: count 5-minute slots with data, not readings vs the recent interval
Publish Container / publish (push) Successful in 2m29s

Coverage divided each day's reading count by 1440/median-recent-interval, so days logged at a
slower pace (100 s, 120 s) showed as 50-60% complete once uploads sped up to 60 s. Each day now
records which of its 288 five-minute slots have a reading (backfilled from raw files on startup),
so coverage is independent of upload interval. Older days without slot data fall back to the old maths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-20 10:46:44 -04:00
1 parent b8ce8ce149
commit 9515d1af00
2 files changed
+39 -4

No files matched your search

+11 -4
View File
@@ -301,7 +301,13 @@ function medianIntervalMin() {
d.sort((a, b) => a - b); d.sort((a, b) => a - b);
return d[d.length >> 1] / 60000; return d[d.length >> 1] / 60000;
} }
function coverageFor(row, perDay) { return row && perDay ? Math.min(100, (row.n / perDay) * 100) : null; } // Share of the day's 288 five-minute slots that have at least one reading, so it doesn't matter whether the
// station reports every 60, 100 or 120 s. Days saved before slots were tracked fall back to reading count.
function coverageFor(row, perDay) {
if (!row) return null;
if (row.slots != null) return Math.min(100, (row.slots / 288) * 100);
return perDay ? Math.min(100, (row.n / perDay) * 100) : null;
}
function renderHealthCard() { function renderHealthCard() {
const host = $('healthCard'); const host = $('healthCard');
@@ -311,7 +317,8 @@ function renderHealthCard() {
const interval = medianIntervalMin(); const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null; const perDay = interval ? 1440 / interval : null;
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30); const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
const cov = perDay && past.length ? mean(past.map((d) => coverageFor(d, perDay))) : null; const covVals = past.map((d) => coverageFor(d, perDay)).filter((v) => v != null);
const cov = covVals.length ? mean(covVals) : null;
const t = todayRow(); const t = todayRow();
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv"> host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div> <div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
@@ -530,8 +537,8 @@ function renderInsights() {
const interval = medianIntervalMin(); const interval = medianIntervalMin();
const perDay = interval ? 1440 / interval : null; const perDay = interval ? 1440 / interval : null;
const cov = past.slice(-45); const cov = past.slice(-45);
if (perDay) { if (cov.length) {
const vals = cov.map((d) => +coverageFor(d, perDay).toFixed(0)); const vals = cov.map((d) => +(coverageFor(d, perDay) ?? 0).toFixed(0));
const ds = barDataset('Coverage %', vals, c.cool); const ds = barDataset('Coverage %', vals, c.cool);
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb')); ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } }); makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
+28
View File
@@ -220,6 +220,27 @@ function stationLocalDay(r) {
return f.format(new Date(r.dateutc)); return f.format(new Date(r.dateutc));
} }
// Local 5-minute slot (0-287) of a reading, for interval-independent coverage.
const slotFmtCache = new Map();
function stationLocalSlot(r) {
const tz = r.tz || stationTzFallback;
let f = slotFmtCache.get(tz);
if (!f) {
try { f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
catch { f = new Intl.DateTimeFormat('en-GB', { timeZone: stationTzFallback, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }); }
slotFmtCache.set(tz, f);
}
const p = Object.fromEntries(f.formatToParts(new Date(r.dateutc)).map(x => [x.type, x.value]));
return Math.min(287, Math.floor((Number(p.hour) * 60 + Number(p.minute)) / 5));
}
// day.sl is a 288-char string of 0/1: which 5-minute slots of the local day have at least one reading.
function markSlot(day, slot) {
const sl = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288);
if (sl[slot] === '1') { day.sl = sl; return false; }
day.sl = sl.slice(0, slot) + '1' + sl.slice(slot + 1);
return true;
}
// Per-day set of dateutc values already on disk. Shared by the live recorder and // Per-day set of dateutc values already on disk. Shared by the live recorder and
// the backfill so neither can write a reading the other (or a prior run) saved. // the backfill so neither can write a reading the other (or a prior run) saved.
const stationSeen = new Map(); const stationSeen = new Map();
@@ -365,6 +386,7 @@ function updateRecordsFromReading(obs) {
applyReadingToRecords(obs); applyReadingToRecords(obs);
const day = recordsStore.days[stationLocalDay(obs)]; const day = recordsStore.days[stationLocalDay(obs)];
day.n = (day.n || 0) + 1; day.n = (day.n || 0) + 1;
markSlot(day, stationLocalSlot(obs));
addAggregates(day, obs); addAggregates(day, obs);
recordsDirty = true; recordsDirty = true;
} }
@@ -375,6 +397,7 @@ function rebuildRecordsFromFiles() {
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; } try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
const counts = {}; const counts = {};
const fresh = {}; const fresh = {};
const slotsFresh = {};
let scanned = 0; let scanned = 0;
for (const f of files) { for (const f of files) {
try { try {
@@ -387,6 +410,7 @@ function rebuildRecordsFromFiles() {
applyReadingToRecords(r); applyReadingToRecords(r);
const k = stationLocalDay(r); const k = stationLocalDay(r);
counts[k] = (counts[k] || 0) + 1; counts[k] = (counts[k] || 0) + 1;
markSlot(slotsFresh[k] || (slotsFresh[k] = {}), stationLocalSlot(r));
addAggregates(fresh[k] || (fresh[k] = {}), r); addAggregates(fresh[k] || (fresh[k] = {}), r);
scanned++; scanned++;
} }
@@ -396,6 +420,9 @@ function rebuildRecordsFromFiles() {
for (const [k, n] of Object.entries(counts)) { for (const [k, n] of Object.entries(counts)) {
const day = recordsStore.days[k]; const day = recordsStore.days[k];
day.n = Math.max(day.n || 0, n); day.n = Math.max(day.n || 0, n);
// union with what live recording already marked (pruned raw files must not lose coverage)
const fs_ = slotsFresh[k]?.sl;
if (fs_) { const cur = day.sl && day.sl.length === 288 ? day.sl : '0'.repeat(288); day.sl = [...fs_].map((c, i) => (c === '1' || cur[i] === '1') ? '1' : '0').join(''); }
// Replace aggregates only when the files hold at least as many samples as the store did // Replace aggregates only when the files hold at least as many samples as the store did
const f = fresh[k]; const f = fresh[k];
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; } if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
@@ -1992,6 +2019,7 @@ app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
if (limit) entries = entries.slice(-limit); if (limit) entries = entries.slice(-limit);
res.json(entries.map(([d, v]) => ({ res.json(entries.map(([d, v]) => ({
d, n: v.n || 0, d, n: v.n || 0,
slots: v.sl ? (v.sl.match(/1/g) || []).length : null, // 5-min slots with data (of 288)
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null, hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null, avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null, gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,