feat: forecast change log

Record each forecast change (Zambretti letter transition) with a
timestamp into a 24-entry ring buffer, persisted to LittleFS and
restored on boot. New /api/forecasts endpoint and a 'Forecast log'
panel in the web UI (newest first, category colour, timestamp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:40:28 +07:00
co-authored by Claude Opus 4.8
parent f4a9a04207
commit f1fe0b728f
8 changed files with 157 additions and 0 deletions
+47
View File
@@ -100,6 +100,18 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
.axx{display:flex; justify-content:space-between; padding:0 46px; margin-top:6px;
font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.7rem; color:var(--muted)}
.hint{color:var(--muted); font-size:.78rem; margin:10px 0 0}
.cap{color:var(--muted); font-size:.74rem; letter-spacing:.06em}
/* Forecast log */
.log{list-style:none; margin:2px 0 0; padding:0}
.log li{display:flex; align-items:center; gap:13px; padding:12px 0; border-top:1px solid var(--line)}
.log li:first-child{border-top:0}
.log .dot{width:9px; height:9px; border-radius:50%; flex:0 0 auto}
.log .lt{flex:1; min-width:0}
.log .lt b{display:block; font-family:var(--serif); font-weight:600; color:var(--enamel); font-size:1.02rem; line-height:1.25}
.log .lc{font-size:.66rem; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); margin-top:2px}
.log time{font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.76rem; color:var(--muted); white-space:nowrap; flex:0 0 auto}
.log .empty{color:var(--muted); padding:6px 0}
/* Settings */
details.set summary{
@@ -180,6 +192,15 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
<p class="hint" id="hint">Collecting data&hellip;</p>
</section>
<!-- Forecast log -->
<section class="panel panel-pad">
<div class="head">
<h2>Forecast log</h2>
<span class="cap" id="log-cap"></span>
</div>
<ol class="log" id="log"></ol>
</section>
<!-- Settings -->
<details class="panel set">
<summary>Station settings <span class="caret">Adjust</span></summary>
@@ -318,9 +339,35 @@ async function refresh(){
// absolute already implied; keep footer clean
}catch(e){}
await drawChart();
await drawLog();
}
function fmtTime(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
function fmtStamp(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCMonth()+1)+"-"+p(d.getUTCDate())+" "+p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
async function drawLog(){
var data;
try{ data=await (await fetch("/api/forecasts")).json(); }catch(e){ return; }
var ol=document.getElementById("log"), cap=document.getElementById("log-cap");
ol.innerHTML="";
if(!data.length){
var li=document.createElement("li"); li.className="empty";
li.textContent="No forecasts yet — the first call appears after about three hours.";
ol.appendChild(li); cap.textContent=""; return;
}
data.forEach(function(e){
var li=document.createElement("li");
var dot=document.createElement("span"); dot.className="dot";
dot.style.background=CATCOLOR[e.cat]||"#93A3BE"; li.appendChild(dot);
var lt=document.createElement("div"); lt.className="lt";
var b=document.createElement("b"); b.textContent=e.text; lt.appendChild(b);
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=e.cat; lt.appendChild(lc);
li.appendChild(lt);
var tm=document.createElement("time"); tm.textContent=fmtStamp(e.t); li.appendChild(tm);
ol.appendChild(li);
});
cap.textContent=data.length+(data.length>1?" changes":" change");
}
function fillAxis(id,mx,mn,deg){
var box=document.getElementById(id); box.innerHTML="";
[mx,(mx+mn)/2,mn].forEach(function(v){