Simplify status interface
Deploy Solar Relay / deploy (push) Successful in 1m2s

This commit is contained in:
franzz committed 2026-11-02 23:14:13 +01:00
1 parent cd7ddd3cf6
commit 89621bc7c3
15 files changed
+289 -74

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
"""Local Fronius recorder and durable HTTP relay (AGPL-3.0-or-later)."""
__version__ = "0.2.0"
__version__ = "0.3.0"
+3 -2
View File
@@ -102,7 +102,7 @@ def _parser():
commands.add_parser("sample", help="Record one observation and print its normalized readings")
commands.add_parser("upload", help="Upload the pending queue, stopping on the first failure")
commands.add_parser("status", help="Show local archive/queue status without contacting the inverter")
commands.add_parser("prune", help="Delete records acknowledged more than retention_days ago")
commands.add_parser("prune", help="Delete expired whole local days after acknowledgement retention")
for name, help_text in [
("export", "Export archived samples as JSON Lines, including already uploaded samples"),
("replay", "Requeue archived samples for upload, preserving their IDs"),
@@ -125,7 +125,8 @@ def main(argv=None):
until = date_bound(args.until, config.collector.timezone)
if start is not None and until is not None and start >= until:
raise ValueError("--from must be earlier than --until")
store = Store(config.collector.archive, config.collector.retention_days)
store = Store(config.collector.archive, config.collector.retention_days,
timezone_name=config.collector.timezone)
if args.command == "run":
return _run(config, store)
if args.command == "sample":
+10 -1
View File
@@ -1,7 +1,7 @@
"""Private runtime configuration; credential fields are excluded from reprs."""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import math
from pathlib import Path
import re
@@ -18,6 +18,15 @@ def utc_text(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def retention_cutoff(now: datetime, days: int, timezone_name: str) -> datetime:
"""Start of the oldest retained local calendar day, returned in UTC."""
if now.tzinfo is None:
raise ValueError("Retention time must include its timezone")
zone = ZoneInfo(timezone_name)
first = now.astimezone(zone).date() - timedelta(days=days)
return datetime.combine(first, datetime.min.time(), zone).astimezone(timezone.utc)
def date_bound(value: str | None, timezone_name: str) -> str | None:
"""Date bounds are local midnights; timestamp bounds may specify an offset."""
if value is None:
+9 -4
View File
@@ -61,7 +61,8 @@ def daily_coverage(rows, timezone_name, days, now=None):
date = datetime.fromtimestamp(observed / 1000, zone).date().isoformat()
observations[date] += 1
bucket = observed // BUCKET_MS * BUCKET_MS
mask = sum(1 << index for index in range(3) if row[2 + index * 2])
# Archive rows retain meter flags, but status coverage tracks Energy and Boiler only.
mask = int(bool(row[2])) | (int(bool(row[6])) << 1)
slots_by_time[bucket] = slots_by_time.get(bucket, 0) | mask
result = []
for offset in range(days + 1):
@@ -71,18 +72,22 @@ def daily_coverage(rows, timezone_name, days, now=None):
start_ms = round(start.timestamp() * 1000)
expected = round((end.timestamp() - start.timestamp()) * 1000) // BUCKET_MS
elapsed = expected if date < today else max(0, min(expected, (upper - start_ms) // BUCKET_MS))
# This is extraction coverage: show the current slot as soon as its
# record is saved, without waiting for the five-minute period to end.
# An unrecorded current slot stays pending and is not counted missing.
if date == today and elapsed < expected and start_ms + elapsed * BUCKET_MS in slots_by_time:
elapsed += 1
slots = []
for index in range(expected):
slots.append(slots_by_time.get(start_ms + index * BUCKET_MS, 0) if index < elapsed else -1)
complete = sum(mask == 7 for mask in slots)
complete = sum(mask == 3 for mask in slots)
count = observations[date.isoformat()]
status = "in_progress" if date == today else "complete" if complete == expected else "partial" if count else "missing"
result.append({
"date": date.isoformat(), "day_start": utc_text(start), "expected": expected, "elapsed": elapsed,
"complete": complete, "missing": elapsed - complete, "samples": count, "status": status,
"energy_complete": sum(mask >= 0 and bool(mask & 1) for mask in slots),
"meter_complete": sum(mask >= 0 and bool(mask & 2) for mask in slots),
"boiler_complete": sum(mask >= 0 and bool(mask & 4) for mask in slots),
"boiler_complete": sum(mask >= 0 and bool(mask & 2) for mask in slots),
"slots": slots,
})
return result
+5 -5
View File
@@ -11,7 +11,7 @@ import os
from uuid import NAMESPACE_URL, uuid5
from zoneinfo import ZoneInfo
from .config import utc_text
from .config import retention_cutoff, utc_text
from .coverage import milliseconds
from .http import TransportError
from .solarweb import ChartDataError, SolarWebClient
@@ -92,14 +92,14 @@ class HistoryRecovery:
def recover(self, start=None, until=None, *, now=None, stop=None):
now = now or datetime.now(timezone.utc)
latest = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp()) // 300 * 300
# Check the whole retained window, including days before any live observation.
# Round up so recovery never requests an interval older than the cutoff.
earliest = math.ceil((now - timedelta(days=self.store.retention_days)).timestamp() / 300) * 300
# Always include the entire oldest local day, even across a DST change.
earliest = int(retention_cutoff(now, self.store.retention_days, self.zone.key).timestamp())
lower = milliseconds(start) // 300_000 * 300 if start else earliest
upper = min(milliseconds(until) // 300_000 * 300, latest) if until else latest
if lower >= upper:
return 0
if (start is not None or until is not None) and upper - lower > 30 * 86400:
manual_limit = datetime.fromtimestamp(lower, self.zone) + timedelta(days=30)
if (start is not None or until is not None) and upper > manual_limit.timestamp():
raise ValueError("Recover at most 30 days at once")
stamp = lambda epoch: utc_text(datetime.fromtimestamp(epoch, timezone.utc))
present = self.store.energy_slots(self.source_id, stamp(lower), stamp(upper))
+1 -1
View File
@@ -109,7 +109,7 @@ class StatusServer:
now = datetime.now(timezone.utc)
attempt = archive["last_observation"]
age = (now.timestamp() * 1000 - milliseconds(attempt)) / 1000 if attempt else None
errors = list(archive["last_errors"])
errors = [source for source in archive["last_errors"] if source != "meter"]
success = self.store.last_success(self.config.collector.source_id)
status = "waiting" if age is None else "stale" if age > max(90, self.config.collector.poll_interval_seconds * 3) or age < -90 else "partial" if errors or success != attempt else "ok"
body = {
+27 -10
View File
@@ -13,11 +13,12 @@ from pathlib import Path
import tempfile
import threading
import time
from zoneinfo import ZoneInfo
import pyarrow as pa
import pyarrow.parquet as pq
from .config import utc_text
from .config import retention_cutoff, utc_text
from .coverage import daily_coverage, milliseconds, sample_coverage
NUMBERS = (
@@ -45,12 +46,13 @@ class StoreError(OSError):
class Store:
def __init__(self, path: Path, retention_days=30):
def __init__(self, path: Path, retention_days=30, *, timezone_name="Europe/Zurich"):
self.path = Path(path)
if self.path.is_file():
raise StoreError("collector.archive must be a directory")
self.path.mkdir(parents=True, exist_ok=True, mode=0o700)
self.retention_days = retention_days
self.zone = ZoneInfo(timezone_name)
self._thread_lock = threading.RLock()
descriptor = os.open(self.path / ".lock", os.O_CREAT | os.O_WRONLY, 0o600)
os.close(descriptor)
@@ -190,16 +192,32 @@ class Store:
return count
def prune(self, *, now=None):
"""Retain pending records; age acknowledged records from their receipt time."""
cutoff = utc_text((now or datetime.now(timezone.utc)) - timedelta(days=self.retention_days))
"""Remove whole local observation days only after all their acknowledgements expire."""
boundary = retention_cutoff(now or datetime.now(timezone.utc), self.retention_days, self.zone.key)
cutoff, first_day = utc_text(boundary), boundary.astimezone(self.zone).date()
deleted = 0
local_day = lambda row: datetime.fromtimestamp(row["observed_ms"] / 1000, self.zone).date()
with self._locked(write=True):
partitions, expired, protected = [], set(), set()
# A local day can span two UTC partitions. One pending or recently
# acknowledged record protects the rest of its local day as well.
for path in self._files():
index = self._read(path, ["uploaded_at"])
if not any(row["uploaded_at"] is not None and row["uploaded_at"] < cutoff for row in index):
index = self._read(path, ["observed_ms", "uploaded_at"])
days = set()
for row in index:
day = local_day(row)
days.add(day)
if day < first_day and row["uploaded_at"] is not None and row["uploaded_at"] < cutoff:
expired.add(day)
else:
protected.add(day)
partitions.append((path, days))
expired -= protected
for path, days in partitions:
if not days & expired:
continue
rows = self._read(path)
retained = [row for row in rows if row["uploaded_at"] is None or row["uploaded_at"] >= cutoff]
retained = [row for row in rows if local_day(row) not in expired]
self._write(path, retained)
deleted += len(rows) - len(retained)
return deleted
@@ -229,7 +247,6 @@ class Store:
}
def coverage(self, source_id, timezone_name, days, now=None):
from zoneinfo import ZoneInfo
now = now or datetime.now(timezone.utc)
first = now.astimezone(ZoneInfo(timezone_name)).date() - timedelta(days=days)
lower = round(datetime.combine(first, datetime.min.time(), ZoneInfo(timezone_name)).timestamp() * 1000)
@@ -247,8 +264,8 @@ class Store:
def last_success(self, source_id):
with self._locked():
for path in reversed(self._files()):
rows = self._read(path, ["source_id", "observed_ms", "power_ok", "meter_ok", "boiler_ok"])
stamps = [row["observed_ms"] for row in rows if row["source_id"] == source_id and row["power_ok"] and row["meter_ok"] and row["boiler_ok"]]
rows = self._read(path, ["source_id", "observed_ms", "power_ok", "boiler_ok"])
stamps = [row["observed_ms"] for row in rows if row["source_id"] == source_id and row["power_ok"] and row["boiler_ok"]]
if stamps:
return utc_text(datetime.fromtimestamp(max(stamps) / 1000, timezone.utc))
return None
+11 -12
View File
@@ -24,19 +24,9 @@
<p id="notice" class="notice" role="alert" hidden></p>
<section class="metrics" aria-label="Current collection status">
<article class="metric"><p class="metric-label">Last extraction</p><strong id="last-extraction">—</strong><p id="last-extraction-date">No observation received yet</p><small id="last-success">Last successful extraction: —</small></article>
<article class="metric"><p class="metric-label">Yesterday’s coverage</p><strong id="yesterday">— <span>/ —</span></strong><p id="yesterday-detail">Five-minute intervals</p><small>Energy, meter and boiler status</small></article>
<article class="metric"><p class="metric-label">Yesterday’s coverage</p><strong id="yesterday">— <span>/ —</span></strong><p id="yesterday-detail">Five-minute intervals</p><small>Energy and boiler status</small></article>
<article class="metric"><p class="metric-label">Waiting to upload</p><strong id="pending">—</strong><p id="upload-detail">Checking delivery</p><small id="last-upload">Last acknowledgement: —</small></article>
<article class="metric"><p class="metric-label">Local archive</p><strong id="samples">—</strong><p>Observations retained in Parquet</p><small id="retention">Acknowledged readings kept for 30 days</small></article>
</section>
<section class="panel">
<div class="panel-heading"><div><p class="eyebrow">DAILY COVERAGE</p><h2>Five-minute completeness</h2></div><label class="range">History <select id="history"><option value="7">7 days</option><option value="14" selected>14 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label></div>
<p class="panel-note">Each count shows five-minute intervals with valid data out of the elapsed intervals for that day. Complete intervals have all three sources. Polling does not guarantee continuous observation. Solar.web recovery fills Energy only.</p>
<dl class="coverage-definitions">
<div><dt>Energy</dt><dd id="energy-description">Solar production, household consumption and electricity imported from or exported to the grid. Available from live readings or recovered Solar.web history.</dd></div>
<div><dt>Meter</dt><dd id="meter-description">Running totals of electricity bought from and sold to the grid. Requires live meter readings; Solar.web history cannot fill these counters.</dd></div>
<div><dt>Boiler</dt><dd id="boiler-description">Boiler control output: enabled or disabled. Both states count as valid data. Heating energy is not measured, and Solar.web cannot recover past switch states.</dd></div>
</dl>
<div class="table-scroll"><table><thead><tr><th scope="col">Day</th><th scope="col">Complete intervals</th><th scope="col" class="source-column" aria-describedby="energy-description">Energy</th><th scope="col" class="source-column" aria-describedby="meter-description">Meter</th><th scope="col" class="source-column" aria-describedby="boiler-description">Boiler</th><th scope="col">Status</th></tr></thead><tbody id="days"><tr><td colspan="6" class="empty">Waiting for the local archive…</td></tr></tbody></table></div>
<article class="metric"><p class="metric-label">Local archive</p><strong id="samples">—</strong><p>Observations retained in Parquet</p><small id="retention">Retention: 30 full calendar days</small></article>
</section>
<section class="panel detail" aria-label="Selected day interval details">
<div class="panel-heading"><div><p class="eyebrow">FIVE-MINUTE INTERVALS</p><h2 id="selected-date">Select a day</h2></div><div class="legend"><span><i class="complete"></i>Complete</span><span><i class="partial"></i>Partial</span><span><i class="missing"></i>Missing</span><span><i class="future"></i>Upcoming</span></div></div>
@@ -44,6 +34,15 @@
<div id="slots" class="slots" aria-label="Five-minute coverage"></div>
<p id="missing-ranges" class="gap-summary"></p>
</section>
<section class="panel">
<div class="panel-heading"><div><p class="eyebrow">DAILY COVERAGE</p><h2>Five-minute completeness</h2></div><label class="range">History <select id="history"><option value="7">7 days</option><option value="14" selected>14 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label></div>
<p class="panel-note">Each count shows five-minute intervals with valid data out of the intervals assessed so far. Saved extractions appear immediately, including the current interval. Complete intervals have both Energy and Boiler data. Polling does not guarantee continuous observation. Solar.web recovery fills Energy only.</p>
<dl class="coverage-definitions">
<div><dt>Energy</dt><dd id="energy-description">Solar production, household consumption and electricity imported from or exported to the grid. Available from live readings or recovered Solar.web history.</dd></div>
<div><dt>Boiler</dt><dd id="boiler-description">Boiler control output: enabled or disabled. Both states count as valid data. Heating energy is not measured, and Solar.web cannot recover past switch states.</dd></div>
</dl>
<div class="table-scroll"><table><thead><tr><th scope="col">Day</th><th scope="col">Complete intervals</th><th scope="col" class="source-column" aria-describedby="energy-description">Energy</th><th scope="col" class="source-column" aria-describedby="boiler-description">Boiler</th><th scope="col">Status</th></tr></thead><tbody id="days"><tr><td colspan="5" class="empty">Waiting for the local archive…</td></tr></tbody></table></div>
</section>
<footer><span>Read-only status · refreshes every 15 seconds</span><span id="version">solar-relay</span></footer>
<noscript><p class="notice">Enable JavaScript to load extraction and coverage status.</p></noscript>
</main>
+1 -1
View File
@@ -1,6 +1,6 @@
:root{color-scheme:dark;--bg:#101518;--panel:#171e22;--line:#2b353a;--ink:#eef1ed;--muted:#9ba9ac;--amber:#e8ba70;--green:#7ad6ac;--red:#e68d86;--radius:12px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-synthesis:none}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible{outline:2px solid var(--amber);outline-offset:4px}main{max-width:1216px;margin:auto;padding:0 32px}.masthead{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-size:13px;letter-spacing:.14em;font-weight:750}.brand small{display:block;color:var(--muted);font-size:9px;letter-spacing:.22em;margin-top:5px}.sun{font-size:36px;color:var(--amber);line-height:1}.header-actions{display:flex;align-items:center;gap:16px}.badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:20px;padding:6px 11px;font-size:11px;font-weight:650;line-height:1.3;background:#252e32;color:var(--muted)}.badge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}.badge.ok,.badge.complete{color:var(--green);background:#193a2e}.badge.partial,.badge.in_progress{color:var(--amber);background:#3a3121}.badge.stale{color:var(--red);background:#3d2829}.badge.missing,.badge.waiting,.badge.neutral{color:var(--muted);background:#252e32}.refresh{border:1px solid var(--line);background:transparent;color:var(--ink);border-radius:7px;padding:8px 12px;font-size:13px}.refresh:hover{border-color:var(--muted)}.refresh:disabled{opacity:.45;cursor:wait}.intro{padding:42px 0 28px}.eyebrow{color:var(--amber);font-size:10px;font-weight:700;letter-spacing:.17em;margin:0 0 13px}h1{font-size:clamp(27px,3.4vw,42px);letter-spacing:-.045em;font-weight:550;line-height:1.14;margin:0 0 13px}.description{color:var(--muted);font-size:14px;line-height:1.6;margin:0}.context{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted);margin-top:20px;flex-wrap:wrap}.separator{color:#48565c}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 28px}.metric{border:1px solid var(--line);border-radius:var(--radius);padding:23px 20px;background:var(--panel)}.metric-label{font-size:11px;font-weight:550;color:var(--muted);margin:0 0 18px}.metric strong{display:block;font-size:30px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.04em;line-height:1.1;min-height:34px}.metric strong span{color:var(--muted);font-size:17px;font-weight:400;letter-spacing:normal}.metric p:not(.metric-label){font-size:11px;margin:12px 0 7px;color:#d1d9d7}.metric small{font-size:10px;color:var(--muted);line-height:1.6;display:block}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:22px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 24px 0}.panel-heading .eyebrow{margin-bottom:7px}h2{font-size:19px;font-weight:550;letter-spacing:-.025em;margin:0}.panel-note{font-size:11px;line-height:1.7;color:var(--muted);padding:0 24px;margin:12px 0 22px}.range{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:11px;white-space:nowrap}select{background:#20292e;border:1px solid #354149;border-radius:6px;color:var(--ink);padding:7px 27px 7px 9px;font-size:11px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;font-size:12px;font-variant-numeric:tabular-nums}th{background:#1c252a;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:11px 18px;font-size:9px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}th:first-child,td:first-child{padding-left:24px}td{padding:13px 18px;border-bottom:1px solid #283137;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tr.selected{background:#222d32}tr:hover{background:#202a2f}.day-select{display:block;text-align:left;color:var(--ink);background:transparent;border:0;padding:0;line-height:1.55;font-size:12px}.day-select small{display:block;color:var(--muted);font-size:10px}.coverage-number{display:flex;align-items:baseline;gap:5px;font-size:12px}.coverage-number small{font-size:10px;color:var(--muted)}.progress{height:3px;width:126px;border-radius:2px;background:#303c42;margin-top:7px;overflow:hidden}.progress span{display:block;height:100%;background:var(--green)}.source-count{color:#c1ceca;font-size:11px}.source-count.incomplete{color:var(--amber)}.empty{padding:40px!important;text-align:center;color:var(--muted)}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:6px}.legend i{display:block;width:7px;height:7px;border-radius:2px}.slots{display:flex;gap:5px;padding:0 24px;overflow-x:auto}.hour{display:grid;grid-template-rows:16px repeat(12,5px);gap:3px;flex:1;min-width:14px}.hour-label{font-size:9px;color:var(--muted);font-variant-numeric:tabular-nums;text-align:center}.slot{display:block;border-radius:1px;min-height:5px}.complete{background:#76c9a4}.partial{background:#bd9558}.missing{bacLine truncated
.coverage-definitions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin:0;padding:0 24px 24px}.coverage-definitions dt{font-size:11px;font-weight:650;margin-bottom:6px}.coverage-definitions dd{margin:0;color:var(--muted);font-size:11px;line-height:1.7}
.coverage-definitions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin:0;padding:0 24px 24px}.coverage-definitions dt{font-size:11px;font-weight:650;margin-bottom:6px}.coverage-definitions dd{margin:0;color:var(--muted);font-size:11px;line-height:1.7}
@media(max-width:900px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.metric{padding:20px}.source-column{display:none}.legend{max-width:215px;gap:10px}.slots{gap:4px}.progress{width:115px}}
@media(max-width:560px){.coverage-definitions{grid-template-columns:1fr;gap:14px;padding:0 16px 20px}.coverage-definitions dt,.coverage-definitions dd{font-size:10px}}
@media(max-width:560px){main{padding:0 16px}.masthead{height:82px}.brand{font-size:11px;gap:8px}.sun{font-size:30px}.header-actions{gap:8px}.refresh span{display:none}.refresh{font-size:17px;padding:4px 9px}.badge{font-size:10px;padding:6px 8px}.intro{padding-top:30px}.metrics{gap:10px;margin-bottom:20px}.metric{padding:17px 14px}.metric strong{font-size:26px}.metric strong span{font-size:14px}.metric-label{margin-bottom:14px}.metric small{font-size:9px}.panel-heading{padding:20px 16px 0;align-items:flex-start}h2{font-size:16px}.panel-note{padding:0 16px;font-size:10px}th,td{padding-left:11px;padding-right:11px}th:first-child,td:first-child{padding-left:16px}.progress{width:88px}.range{flex-direction:column;align-items:flex-end;gap:5px}.legend{font-size:9px;gap:7px;max-width:150px}.slots{padding:0 16px;gap:3px}.hour{min-width:10px;grid-template-rows:16px repeat(12,5px);gap:3px}.hour-label{font-size:8px}.gap-summary{padding:16px;font-size:10px}.day-select{font-size:11px}footer{font-size:9px;flex-wrap:wrap}.detail .panel-heading{gap:8px}}
+10 -9
View File
@@ -48,7 +48,7 @@
const fill = addText(progress,'span','');
fill.style.width = (day.elapsed ? day.complete/day.elapsed*100:0)+'%';
row.append(amount);
for(const field of ['energy_complete','meter_complete','boiler_complete']) {
for(const field of ['energy_complete','boiler_complete']) {
const cell = td('source-column');
addText(cell,'span',number(day[field])+'/'+number(day.elapsed),'source-count'+(day[field]<day.elapsed?' incomplete':''));
row.append(cell);
@@ -63,8 +63,9 @@
const day = current.days.find(item => item.date===selected);
if(!day) return;
$('selected-date').textContent = format(day.date+'T12:00:00Z',{weekday:'long',day:'numeric',month:'long',year:'numeric'});
$('selected-summary').textContent = `${number(day.complete)} complete of ${number(day.elapsed)} elapsed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`;
$('selected-summary').textContent = `${number(day.complete)} complete of ${number(day.elapsed)} assessed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`;
const start = Date.parse(day.day_start);
const currentIndex = day.status==='in_progress' ? Math.floor((Date.parse(current.generated_at)-start)/300000) : -1;
const stamp = index => format(new Date(start+index*300000),{hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset',hour12:false});
$('slots').replaceChildren();
let hour;
@@ -74,9 +75,9 @@
addText(hour,'span',format(new Date(start+index*300000),{hour:'2-digit',hour12:false}),'hour-label');
}
const mask = day.slots[index];
const state = mask===7?'complete':mask===-1?'future':mask>0?'partial':'missing';
const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===7?'Complete':mask===-1?'Upcoming':'Incomplete: '+missing.join(', ')}`;
const state = mask===3?'complete':mask===-1?'future':mask>0?'partial':'missing';
const missing = ['Energy','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===3?'Complete':mask===-1?(index===currentIndex?'Awaiting extraction':'Upcoming'):'Incomplete: '+missing.join(', ')}`;
const cell = addText(hour,'span','','slot '+state);
cell.title = text;
cell.setAttribute('role','img');
@@ -84,12 +85,12 @@
}
const ranges = [];
for(let index=0;index<day.elapsed;) {
if(day.slots[index]===7) {index++;continue;}
if(day.slots[index]===3) {index++;continue;}
const from = index;
while(index<day.elapsed && day.slots[index]!==7) index++;
while(index<day.elapsed && day.slots[index]!==3) index++;
ranges.push(`${stamp(from)}–${stamp(index)}`);
}
$('missing-ranges').textContent = ranges.length ? `${number(day.missing)} incomplete intervals. Gaps: ${ranges.slice(0,6).join('; ')}${ranges.length>6?`; plus ${number(ranges.length-6)} more ranges.`:'.'}` : day.status==='in_progress'?'All elapsed intervals are complete. The current interval is still being recorded.':'All five-minute intervals have complete data.';
$('missing-ranges').textContent = ranges.length ? `${number(day.missing)} incomplete intervals. Gaps: ${ranges.slice(0,6).join('; ')}${ranges.length>6?`; plus ${number(ranges.length-6)} more ranges.`:'.'}` : day.status==='in_progress'?(day.slots[currentIndex]===-1?'All previous intervals are complete. Waiting for the current extraction.':'All intervals checked so far have complete data.'):'All five-minute intervals have complete data.';
}
function render(data) {
current = data;
@@ -112,7 +113,7 @@
$('upload-detail').textContent = !data.archive.upload_enabled?'Uploads not configured':data.archive.pending?'Saved locally, awaiting delivery':'All observations acknowledged';
$('last-upload').textContent = 'Last acknowledgement: '+(data.archive.last_upload?date(data.archive.last_upload)+' '+time(data.archive.last_upload):'none yet');
$('samples').textContent = number(data.archive.samples);
$('retention').textContent = `Acknowledged readings kept for ${number(data.archive.retention_days)} days · pending readings retained`;
$('retention').textContent = `Retention: ${number(data.archive.retention_days)} full calendar days · pending days retained`;
$('version').textContent = 'solar-relay '+data.version;
if(!selected || !data.days.some(day=>day.date===selected)) selected=(data.days.find(day=>day.samples>0)||yesterday||data.days[0]).date;
renderDays();