tuas-crossing / static /index.html
munyew's picture
feat: ETT today-vs-lastweek chart + stale-dot amber colour
74a8105 verified
Raw
History Blame Contribute Delete
41.4 kB
<!DOCTYPE html>
<!-- ===========================================================================
DEPLOYED FILE — canonical source of truth for the Tuas Crossing FRONTEND.
Ships to munyew/tuas-crossing as static/index.html via:
python C:\TuasData\deploy_to_hf.py
Edit THIS file only. Do NOT edit the archived decoys:
C:\TuasData\tuas_index_content.html.UNUSED and *.UNUSED-decoy-with-alerts
=========================================================================== -->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0c1016" />
<title>Tuas Crossing</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<style>
:root{
--bg:#0c1016; --panel:#141b24; --panel2:#1b2530; --line:#243140;
--txt:#e6edf3; --muted:#8da2b5; --accent:#38bdf8;
--green:#22c55e; --lime:#84cc16; --amber:#f59e0b; --red:#ef4444; --grey:#475569;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;padding-bottom:env(safe-area-inset-bottom)}
header{padding:18px 16px 8px;position:sticky;top:0;background:linear-gradient(180deg,#0c1016 70%,rgba(12,16,22,0));z-index:5}
h1{font-size:20px;margin:0;letter-spacing:.3px}
.sub{color:var(--muted);font-size:12px;margin-top:3px}
.wrap{max-width:760px;margin:0 auto;padding:0 12px 40px}
.tabs{display:flex;gap:8px;margin:14px 0 6px}
.tab{flex:1;text-align:center;padding:10px;border-radius:12px;background:var(--panel);
color:var(--muted);font-weight:600;font-size:14px;border:1px solid var(--line);cursor:pointer}
.tab.active{color:var(--txt);background:var(--panel2);border-color:var(--accent)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;margin:10px 0}
.eta{display:flex;align-items:baseline;gap:10px}
.eta .num{font-size:46px;font-weight:800;line-height:1}
.eta .unit{font-size:15px;color:var(--muted)}
.eta .lbl{font-size:13px;color:var(--muted);margin-bottom:4px}
.corridor{display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px}
.road{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:10px;background:var(--panel2)}
.dot{width:14px;height:14px;border-radius:50%;flex:none;box-shadow:0 0 10px currentColor}
.road .name{font-weight:600;font-size:14px}
.road .meta{color:var(--muted);font-size:12px;margin-top:2px}
.road .state{margin-left:auto;font-weight:700;font-size:13px}
.cams{display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:520px){.cams{grid-template-columns:1fr}}
.cam{border-radius:10px;overflow:hidden;background:#000;border:1px solid var(--line);position:relative}
.cam img{width:100%;display:block;aspect-ratio:16/10;object-fit:cover}
.cam .tag{position:absolute;left:6px;top:6px;background:rgba(0,0,0,.6);font-size:11px;padding:2px 6px;border-radius:6px}
.inc{font-size:13px;padding:8px 10px;border-left:3px solid var(--grey);background:var(--panel2);border-radius:6px;margin:6px 0;color:#cdd9e5}
.inc.near{border-left-color:var(--amber)}
.inc .ty{font-weight:700;color:var(--amber);font-size:11px;text-transform:uppercase;letter-spacing:.4px}
h2{font-size:14px;color:var(--muted);margin:6px 2px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.heat{overflow-x:auto}
table.hm{border-collapse:collapse;font-size:10px}
table.hm td,table.hm th{width:18px;height:18px;text-align:center;color:#0c1016;font-weight:700}
table.hm th{color:var(--muted);font-weight:600;background:none;width:auto;padding:0 4px}
table.hm td.empty{background:var(--panel2);color:var(--muted)}
.legend{display:flex;gap:10px;align-items:center;font-size:11px;color:var(--muted);margin:8px 2px;flex-wrap:wrap}
.legend i{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:4px;vertical-align:-2px}
/* sparkline chart */
.spark-wrap{position:relative;padding:0 2px}
.spark-yaxis{position:absolute;left:0;top:0;bottom:24px;width:54px;display:flex;flex-direction:column;justify-content:space-between;pointer-events:none}
.spark-yaxis .yl{font-size:10px;color:var(--muted);line-height:1;white-space:nowrap}
.spark-yaxis .yl.top{color:#22c55e}
.spark-yaxis .yl.bot{color:#ef4444}
.spark-inner{margin-left:56px;position:relative}
.spark-reflines{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none}
.spark-refline{position:absolute;left:0;right:0;border-top:1px dashed rgba(255,255,255,0.10)}
.spark{display:flex;align-items:flex-end;gap:1px;height:80px;padding:0}
.spark .b{flex:1;border-radius:2px 2px 0 0;min-height:2px;background:var(--accent)}
.spark .b.empty{background:var(--panel2)}
.spark .b.quiet{background:repeating-linear-gradient(45deg,#243140 0px,#243140 3px,#1b2530 3px,#1b2530 6px);opacity:0.5}
.spark-xaxis{display:flex;margin-left:56px}
.spark-xaxis .xl{font-size:10px;color:var(--muted);text-align:center;flex:1}
.spark-xaxis .xl.lbl{color:var(--muted)}
.spark-xaxis .xl.blank{visibility:hidden}
.spark-caption{font-size:11px;color:var(--muted);margin-top:6px;text-align:center}
.muted{color:var(--muted);font-size:12px}
.hide{display:none}
.pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;background:var(--panel2);color:var(--muted);border:1px solid var(--line)}
/* live dot inside the status pill */
.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:1px;background:var(--green);animation:pulse-dot 1.8s ease-in-out infinite}
.live-dot.stale{background:var(--amber)}
@keyframes pulse-dot{
0%,100%{opacity:1;transform:scale(1)}
50%{opacity:.45;transform:scale(.7)}
}
/* ETT chart */
.ett-wrap{position:relative;margin:0 0 4px}
.ett-canvas-box{position:relative;height:110px;margin-left:44px}
.ett-canvas-box canvas{position:absolute;top:0;left:0;width:100%;height:100%}
.ett-yaxis{position:absolute;left:0;top:0;bottom:0;width:42px;display:flex;flex-direction:column;justify-content:space-between;pointer-events:none}
.ett-yl{font-size:10px;color:var(--muted);line-height:1;text-align:right;padding-right:4px}
.ett-xaxis{display:flex;margin-left:44px;margin-top:2px}
.ett-xl{font-size:10px;color:var(--muted);text-align:center;flex:1}
.ett-xl.blank{visibility:hidden}
.ett-legend{display:flex;gap:14px;justify-content:center;font-size:11px;color:var(--muted);margin:6px 0 2px}
.ett-legend span{display:flex;align-items:center;gap:5px}
.ett-legend i{display:inline-block;width:22px;height:2px;vertical-align:middle}
.ett-legend i.solid{background:var(--accent)}
.ett-legend i.dotted{background:none;border-top:2px dashed var(--lime)}
.err{color:var(--red);font-size:12px}
.freshbar{margin-top:8px;font-size:12px;padding:7px 11px;border-radius:10px;display:flex;align-items:center;gap:8px}
.freshbar.ok{background:rgba(34,197,94,.10);border:1px solid rgba(34,197,94,.35);color:#bbf7d0}
.freshbar.warn{background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.5);color:#fecaca;font-weight:600}
.freshbar .ico{font-size:13px}
.alert-btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{appearance:none;border:1px solid var(--accent);background:var(--accent);color:#06121c;
font-weight:700;font-size:14px;padding:11px 16px;border-radius:10px;cursor:pointer;flex:1;min-width:160px}
.btn.ghost{background:transparent;color:var(--accent)}
.btn:disabled{opacity:.55;cursor:default}
.btn.ok{border-color:var(--green);background:var(--green);color:#06121c}
.rule-box{margin-top:14px;border-top:1px solid var(--line);padding-top:12px}
.rule-title{font-weight:700;font-size:14px;margin-bottom:8px}
.rule-days{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.rule-day{padding:8px 0;flex:1;min-width:38px;text-align:center;border-radius:8px;background:var(--panel2);
border:1px solid var(--line);color:var(--muted);font-size:13px;font-weight:600;cursor:pointer;user-select:none}
.rule-day.on{background:var(--accent);color:#06121c;border-color:var(--accent)}
.rule-row{display:flex;gap:14px;margin-bottom:6px;flex-wrap:wrap}
.rule-row label,.rule-thr{font-size:13px;color:var(--txt);display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.rule-thr{margin:8px 0}
.rule-box input[type=time],.rule-box input[type=number]{background:var(--panel2);border:1px solid var(--line);
color:var(--txt);border-radius:8px;padding:8px;font-size:14px}
.rule-box input[type=number]{width:64px;text-align:center}
.rule-thr input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent);flex:none}
.rule-summary{font-size:13px;color:#bbf7d0;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.3);
border-radius:8px;padding:8px 10px;margin-bottom:10px}
footer{text-align:center;color:var(--muted);font-size:11px;margin-top:20px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>🚗 Tuas Crossing <span class="pill" id="status"><span class="live-dot" id="liveDot"></span><span id="statusTxt"></span></span></h1>
<div class="sub">SG → JB outbound · Second Link corridor · <span id="updated">loading…</span></div>
<div id="freshbar" class="freshbar hide"></div>
<div class="tabs">
<div class="tab active" data-view="now">Now</div>
<div class="tab" data-view="when">When to cross</div>
</div>
</div>
</header>
<div class="wrap">
<!-- NOW -->
<section id="now">
<div class="card" id="totalCard">
<div class="lbl">Total crossing — drive + booth queue</div>
<div class="eta"><span class="num" id="totalNum"></span><span class="unit">min</span></div>
<div id="totalBreakdown" class="muted" style="margin-top:6px;font-size:12.5px;line-height:1.5"></div>
</div>
<div class="card">
<div class="lbl">Drive — AYE → Tuas Checkpoint <span style="color:var(--green)">(measured, live)</span></div>
<div class="eta"><span class="num" id="eta"></span><span class="unit">min</span></div>
<div class="corridor" id="corridor"></div>
</div>
<h2>Live cameras</h2>
<div class="cams" id="cams"><div class="muted">loading cameras…</div></div>
<h2>Incidents</h2>
<div id="incidents"><div class="muted">loading…</div></div>
<h2>🔔 Crossing alerts <span style="text-transform:none;font-weight:400">(beta)</span></h2>
<div class="card" id="alertsCard">
<div class="muted" id="alertMsg" style="margin-bottom:10px">Get a buzz on this phone when the crossing is good (or bad) to go.</div>
<div class="alert-btns">
<button class="btn" id="btnEnable">Enable alerts on this phone</button>
<button class="btn ghost hide" id="btnTest">Send test notification</button>
</div>
<div class="muted" id="alertHint" style="margin-top:10px;font-size:11px"></div>
<div class="rule-box hide" id="ruleBox">
<div class="rule-title">Alert rule</div>
<div class="rule-summary hide" id="ruleSummary"></div>
<div class="muted" style="font-size:11px;margin-bottom:6px">Days</div>
<div class="rule-days" id="ruleDays"></div>
<div class="rule-row">
<label>From <input type="time" id="ruleFrom" value="13:00"></label>
<label>To <input type="time" id="ruleTo" value="20:00"></label>
</div>
<label class="rule-thr"><input type="checkbox" id="goOn">
Alert <b style="color:var(--green)">GO</b> when ETA ≤
<input type="number" id="goVal" min="1" max="180" value="25"> min</label>
<label class="rule-thr"><input type="checkbox" id="waitOn">
Alert <b style="color:var(--amber)">WAIT</b> when ETA ≥
<input type="number" id="waitVal" min="1" max="180" value="45"> min</label>
<div class="alert-btns" style="margin-top:10px">
<button class="btn" id="btnSaveRule">Save rule</button>
<button class="btn ghost" id="btnDeleteRule">Delete rule</button>
</div>
<div class="muted" id="ruleMsg" style="margin-top:8px;font-size:11px">
Numbers are provisional — tune them once ~3–4 weeks of history reveal your real baselines.
</div>
</div>
</div>
<h2>📝 Log your crossing <span style="text-transform:none;font-weight:400">(teaches the estimate)</span></h2>
<div class="card" id="logCard">
<div class="muted" id="logMsg" style="margin-bottom:10px">Tell the app how long your crossing really took, so the camera estimate learns your real times.</div>
<div class="alert-btns">
<button class="btn" id="btnStartCross">Start crossing (joining the queue)</button>
</div>
<div class="rule-row" style="margin-top:12px;align-items:center">
<label>Or just log total minutes:
<input type="number" id="logMins" min="1" max="300" placeholder="e.g. 55"></label>
<button class="btn ghost" id="btnLogMins" style="flex:0 0 auto;min-width:90px">Save</button>
</div>
<div class="muted" id="logCount" style="margin-top:10px;font-size:11px"></div>
</div>
</section>
<!-- WHEN TO CROSS -->
<section id="when" class="hide">
<div class="card" id="ettCard">
<h2 style="margin-top:0">Today vs last week — predicted crossing minutes</h2>
<div class="muted" id="ettMeta" style="margin-bottom:8px">loading…</div>
<div class="ett-wrap">
<div class="ett-yaxis" id="ettYAxis"></div>
<div class="ett-canvas-box" id="ettBox">
<canvas id="ettCanvas"></canvas>
</div>
</div>
<div class="ett-xaxis" id="ettXAxis"></div>
<div class="ett-legend" id="ettLegend"></div>
<div class="spark-caption">Predicted outbound minutes, AYE &rarr; Tuas Checkpoint. Dotted = same day last week (appears once history reaches back a week).</div>
</div>
<div class="card">
<h2 style="margin-top:0">Today so far</h2>
<div class="muted" id="todayLbl"></div>
<div class="spark-wrap" id="sparkWrap">
<div class="spark-yaxis">
<span class="yl top">8=free</span>
<span class="yl">4</span>
<span class="yl bot">1=crawl</span>
</div>
<div class="spark-inner">
<div class="spark-reflines">
<div class="spark-refline" style="top:0%"></div>
<div class="spark-refline" style="top:50%"></div>
<div class="spark-refline" style="top:87.5%"></div>
</div>
<div class="spark" id="spark"></div>
</div>
<div class="spark-xaxis" id="sparkX"></div>
</div>
<div class="spark-caption">avg outbound corridor speed band &middot; 1 = crawling &middot; 8 = free-flow</div>
</div>
<div class="card">
<h2 style="margin-top:0">Average traffic by day &amp; hour</h2>
<div class="muted" id="histMeta">building from history…</div>
<div class="legend">
<span><i style="background:var(--red)"></i>Heavy</span>
<span><i style="background:var(--amber)"></i>Slow</span>
<span><i style="background:var(--lime)"></i>Moderate</span>
<span><i style="background:var(--green)"></i>Free flow</span>
<span>· greener = faster · rows = days, cols = hour</span>
</div>
<div class="heat"><table class="hm" id="heat"></table></div>
</div>
</section>
<footer>
Data: <a href="https://datamall.lta.gov.sg/" target="_blank" rel="noopener">LTA DataMall</a>.
Live cached ~60s. Camera links expire ~5 min.
</footer>
</div>
<script>
const DOW = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"];
// SpeedBand (1 worst .. 8 free-flow) -> colour
function bandColor(b){
if(b==null||isNaN(b)) return null;
if(b>=6.5) return "#22c55e";
if(b>=5) return "#84cc16";
if(b>=3) return "#f59e0b";
return "#ef4444";
}
// tabs
document.querySelectorAll(".tab").forEach(t=>{
t.onclick=()=>{
document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
t.classList.add("active");
const v=t.dataset.view;
document.getElementById("now").classList.toggle("hide", v!=="now");
document.getElementById("when").classList.toggle("hide", v!=="when");
if(v==="when"){ loadHistory(); loadEtt(); }
};
});
function _setStatus(txt, color, stale){
const st = document.getElementById("status");
const lbl = document.getElementById("statusTxt");
const dot = document.getElementById("liveDot");
if(lbl) lbl.textContent = txt;
if(st) st.style.color = color;
if(dot) dot.classList.toggle("stale", !!stale);
}
async function loadLive(){
const st=document.getElementById("status");
try{
const r=await fetch("/api/live",{cache:"no-store"});
const d=await r.json();
if(d.warming){ _setStatus("warming up…","var(--amber)",true);
setTimeout(loadLive, 5000); return; }
if(d.error){ _setStatus("error","var(--red)",true); return; }
window.__live = d; // share live data (the ETT chart's "now" point reads this)
const old = d.ageSec!=null && d.ageSec>180;
const isStale = d.stale||old;
_setStatus(isStale?"stale":"live", isStale?"var(--amber)":"var(--green)", isStale);
document.getElementById("updated").textContent = "updated "+d.updated+" SGT";
document.getElementById("eta").textContent = (d.etaMinutes==null?"–":d.etaMinutes);
// Total crossing = measured drive ETA + camera-estimated booth queue band.
// No fake number if the queue frame is missing/stale (>20 min).
const q=d.queueEstimate, eta=d.etaMinutes, tn=document.getElementById("totalNum"),
tb=document.getElementById("totalBreakdown"), QSTALE=20*60;
if(q && q.available && q.minutesBand && eta!=null && q.ageSec!=null && q.ageSec<=QSTALE){
const lo=q.minutesBand[0], hi=q.minutesBand[1], mid=Math.round((lo+hi)/2);
const bU=(q.bucket||"").toUpperCase(), qm=Math.round(q.ageSec/60);
tn.textContent="~"+(eta+mid);
tb.innerHTML=eta+" min drive <span style=\"color:var(--green)\">(measured)</span> + ~"+lo+"–"+hi+
" min queue <span style=\"color:var(--amber)\">("+bU+", camera-estimated)</span>"+
"<br><span style=\"font-size:11px\">queue from cam 4713 · "+qm+" min ago · provisional bands (calibrating against your real crossings)</span>";
} else {
tn.textContent=(eta!=null?eta:"–");
let why=(!q||!q.available)?"camera unavailable":(q.ageSec>QSTALE?"camera frame stale ("+Math.round(q.ageSec/60)+" min old)":"no live ETA");
tb.innerHTML=(eta!=null?eta+" min drive <span style=\"color:var(--green)\">(measured)</span> · ":"")+
"<span style=\"color:var(--amber)\">queue: "+why+"</span> — showing drive only";
}
// keep the chart's live "now" point fresh while the When-to-cross tab is open
if(window.__ettData && !document.getElementById("when").classList.contains("hide")) renderEtt();
// data-collection freshness badge (detects a silent collector stall)
const fb=document.getElementById("freshbar"); const cf=d.collected;
if(cf && cf.ageMin!=null){
fb.classList.remove("hide");
const ago = cf.ageMin<60 ? cf.ageMin+" min ago"
: Math.floor(cf.ageMin/60)+"h "+(cf.ageMin%60)+"m ago";
if(cf.stale){
fb.className="freshbar warn";
fb.innerHTML='<span class="ico">⚠️</span><span>History data may be stale — last collected '+ago+
' (expected &lt; '+(cf.staleAfterMin||90)+' min). The collector or hourly push may have stalled.</span>';
}else{
fb.className="freshbar ok";
fb.innerHTML='<span class="ico">✅</span><span>History up to date — last collected '+ago+'.</span>';
}
} else { fb.classList.add("hide"); }
// corridor — speed bands sourced from collected history (dataset), with age
const cc=document.getElementById("corridor"); cc.innerHTML="";
if(!d.corridorAvailable){
cc.innerHTML='<div class="muted" style="padding:8px 2px">corridor speed bands awaiting collector…</div>';
} else {
(d.corridor||[]).forEach(c=>{
const div=document.createElement("div"); div.className="road";
const nice=c.road.replace("AYER RAJAH EXPRESSWAY","Ayer Rajah Expwy (AYE)")
.replace("TUAS SECOND CROSSING","Tuas Second Crossing")
.replace("TUAS VIADUCT","Tuas Viaduct");
div.innerHTML=`<span class="dot" style="color:${c.color}"></span>
<div><div class="name">${nice}</div>
<div class="meta">avg band ${c.avgBand==null?"–":c.avgBand} · ${c.n} links</div></div>
<span class="state" style="color:${c.color}">${c.state}</span>`;
cc.appendChild(div);
});
if(d.corridorAgeMin!=null){
const age=d.corridorAgeMin<60 ? d.corridorAgeMin+" min ago"
: Math.floor(d.corridorAgeMin/60)+"h "+(d.corridorAgeMin%60)+"m ago";
const note=document.createElement("div"); note.className="muted";
note.style.cssText="padding:6px 2px 0;font-size:11px";
note.textContent="speed bands as of "+age+" (from collected history)";
cc.appendChild(note);
}
}
// cameras — update in place keyed by id so the previous frame stays
// visible until the new one decodes (avoids a black flash each poll).
const cm=document.getElementById("cams");
const cams=(d.cameras||[]);
if(!cams.length){ cm.innerHTML='<div class="muted">no cameras in range</div>'; }
else{
if(cm.querySelector(".muted")) cm.innerHTML="";
const seen=new Set();
cams.forEach(c=>{
seen.add("cam-"+c.id);
let wrap=document.getElementById("cam-"+c.id);
if(!wrap){
wrap=document.createElement("div"); wrap.className="cam"; wrap.id="cam-"+c.id;
const img=document.createElement("img"); img.alt="camera "+c.id; img.decoding="async";
const camLabels={"4703":"Second Link bridge / checkpoint","4707":"AYE / Tuas West approach","4712":"Tuas Viaduct","4713":"Second Link bridge"};
const tag=document.createElement("div"); tag.className="tag"; tag.textContent=(camLabels[c.id]||("Second Link corridor (cam "+c.id+")"))+" · cam "+c.id;
wrap.appendChild(img); wrap.appendChild(tag); cm.appendChild(wrap);
}
const img=wrap.querySelector("img");
if(img.getAttribute("src")!==c.url) img.src=c.url; // swap only when changed
});
// drop cameras no longer present
Array.from(cm.children).forEach(ch=>{ if(ch.id && !seen.has(ch.id)) ch.remove(); });
}
// incidents
const ic=document.getElementById("incidents"); ic.innerHTML="";
const list=(d.incidents||[]);
if(!list.length){ ic.innerHTML='<div class="muted">no incidents reported</div>'; }
list.slice(0,12).forEach(i=>{
const div=document.createElement("div"); div.className="inc"+(i.near?" near":"");
div.innerHTML=`<span class="ty">${i.type||""}${i.near?" · near corridor":""}</span><br>${i.msg||""}`;
ic.appendChild(div);
});
}catch(e){ _setStatus("offline","var(--red)",true); }
}
let histLoaded=false;
async function loadHistory(force){
if(histLoaded && !force) return;
try{
const r=await fetch("/api/history",{cache:"no-store"});
const d=await r.json();
histLoaded=true;
document.getElementById("histMeta").textContent =
`${d.rows||0} samples over ${d.days||0} day(s). Fills in as history accumulates.`;
// today sparkline
const sp=document.getElementById("spark"); sp.innerHTML="";
const sx=document.getElementById("sparkX"); sx.innerHTML="";
const tl=document.getElementById("todayLbl");
const today=d.today||{}; tl.textContent = today.dow ? today.dow+" — avg corridor speed band by hour" : "no data yet today";
// Quiet hours: 1–5am (collector is paused, no data by design)
const QUIET_START=1, QUIET_END=5;
for(let h=0;h<24;h++){
const b=document.createElement("div");
const v=today.hours? today.hours[h]:null;
const isQuiet = h>=QUIET_START && h<QUIET_END;
if(isQuiet){
// Hatched bar for quiet-hours gap — distinguishes "no collection" from "clear road"
b.className="b quiet"; b.style.height="30%";
b.title=`${h}:00 quiet hours — no collection`;
} else if(v==null){
b.className="b empty"; b.style.height="3px";
b.title=`${h}:00 no data`;
} else {
b.className="b";
// Y-scale: band 1 = 12.5% height, band 8 = 100%; keep a visible floor
b.style.height=Math.max(6,((v-1)/7)*100)+"%";
b.style.background=bandColor(v);
b.title=`${h}:00 band ${v}`;
}
sp.appendChild(b);
}
// X-axis: label every 3 hours (0,3,6,...,21); blank cells in between
for(let h=0;h<24;h++){
const xl=document.createElement("div");
xl.className = (h%3===0) ? "xl lbl" : "xl blank";
xl.textContent = (h%3===0) ? h : "";
sx.appendChild(xl);
}
// heatmap
const hm=document.getElementById("heat"); hm.innerHTML="";
const head=document.createElement("tr"); head.innerHTML="<th></th>";
for(let h=0;h<24;h++){ const th=document.createElement("th"); th.textContent=h; head.appendChild(th); }
hm.appendChild(head);
const order=d.dowOrder||DOW;
order.forEach(day=>{
const tr=document.createElement("tr");
const th=document.createElement("th"); th.textContent=day.slice(0,3); th.style.textAlign="right"; tr.appendChild(th);
const row=(d.heatmap||{})[day]||{};
for(let h=0;h<24;h++){
const td=document.createElement("td");
const v=row[h];
if(v==null){ td.className="empty"; td.textContent=""; }
else{ td.style.background=bandColor(v); td.title=`${day} ${h}:00 · band ${v}`; }
tr.appendChild(td);
}
hm.appendChild(tr);
});
}catch(e){
document.getElementById("histMeta").innerHTML='<span class="err">could not load history</span>';
}
}
// --- ETT chart (today vs last week, predicted crossing minutes) ---
let ettLoaded = false;
// Fetch the historical (15-min dataset) ETT series, store it, then render. The
// render() overlays the LATEST point with the live ~60s ETA so "now" doesn't lag.
async function loadEtt(force){
if(ettLoaded && !force) return;
const meta = document.getElementById("ettMeta");
try{
const r = await fetch("/api/ett",{cache:"no-store"});
const d = await r.json();
if(d.error){ meta.innerHTML='<span class="err">'+d.error+'</span>'; return; }
ettLoaded = true;
window.__ettData = d;
renderEtt();
}catch(e){
const meta2 = document.getElementById("ettMeta");
if(meta2) meta2.innerHTML='<span class="err">could not load ETT data</span>';
}
}
// Draw the chart from stored dataset hours + a live override for the current hour.
function renderEtt(){
const d = window.__ettData;
if(!d) return;
const meta = document.getElementById("ettMeta");
const lwHours = d.lastWeekHours || {};
const hasLW = d.lastWeek && Object.keys(lwHours).length > 0;
// Clone so the live override never mutates the stored dataset series.
const todayHours = Object.assign({}, d.todayHours || {});
// Live override: replace the current hour's point with the live (~60s) ETA so
// the chart's "now" matches the headline number. Historical points untouched.
let liveHour = null, liveEta = null;
const live = window.__live;
if(live && live.etaMinutes != null && live.updated){
liveHour = parseInt(String(live.updated).split(" ")[1].split(":")[0], 10);
liveEta = live.etaMinutes;
if(!isNaN(liveHour) && liveEta != null) todayHours[liveHour] = liveEta;
}
const allVals = [...Object.values(todayHours), ...Object.values(lwHours)];
const dataMax = allVals.length ? Math.max(...allVals) : 60;
const yMax = Math.max(120, Math.ceil((dataMax + 10) / 10) * 10);
const yAxis = document.getElementById("ettYAxis");
yAxis.innerHTML = "";
const ySteps = [];
for(let v = yMax; v >= 0; v -= 30) ySteps.push(v);
ySteps.forEach(v => {
const el = document.createElement("div"); el.className = "ett-yl"; el.textContent = v;
yAxis.appendChild(el);
});
const xAxis = document.getElementById("ettXAxis");
xAxis.innerHTML = "";
for(let h = 0; h < 24; h++){
const el = document.createElement("div");
el.className = (h % 3 === 0) ? "ett-xl" : "ett-xl blank";
el.textContent = (h % 3 === 0) ? h : "";
xAxis.appendChild(el);
}
const box = document.getElementById("ettBox");
const canvas = document.getElementById("ettCanvas");
const W = box.clientWidth || 300;
const H = box.clientHeight || 110;
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, W, H);
const PAD_TOP = 6, PAD_BOT = 6;
const usableH = H - PAD_TOP - PAD_BOT;
function toY(val){ return PAD_TOP + usableH * (1 - val / yMax); }
function toX(hour){ return (hour / 23) * (W - 1); }
ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
ySteps.forEach(v => { const y = toY(v); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); });
function drawLine(hoursObj, color, dashed){
ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.setLineDash(dashed ? [5, 5] : []);
ctx.lineJoin = "round"; ctx.lineCap = "round";
// Separate segment per contiguous run — no interpolation across honest gaps.
let inSeg = false; ctx.beginPath();
for(let h = 0; h < 24; h++){
if(hoursObj[h] != null){
const x = toX(h), y = toY(hoursObj[h]);
if(!inSeg){ ctx.moveTo(x, y); inSeg = true; } else { ctx.lineTo(x, y); }
} else if(inSeg){ ctx.stroke(); ctx.beginPath(); inSeg = false; }
}
if(inSeg) ctx.stroke();
ctx.setLineDash([]);
}
if(hasLW) drawLine(lwHours, "#84cc16", true); // dotted lime = last week
drawLine(todayHours, "#38bdf8", false); // solid accent = today
// "now" marker — always visible, even if it's a lone point inside a gap.
if(liveHour != null && liveEta != null){
const x = toX(liveHour), y = toY(liveEta);
ctx.fillStyle = "#38bdf8"; ctx.beginPath(); ctx.arc(x, y, 4, 0, 2*Math.PI); ctx.fill();
ctx.strokeStyle = "#e6edf3"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 4, 0, 2*Math.PI); ctx.stroke();
}
const leg = document.getElementById("ettLegend");
leg.innerHTML = '<span><i class="solid"></i>Today ('+d.today+')</span>'
+ (hasLW ? '<span><i class="dotted"></i>Last '+d.lastWeek+'</span>' : '')
+ (liveEta != null ? '<span><i style="width:8px;height:8px;border-radius:50%;background:#38bdf8;border:1px solid #e6edf3"></i>now '+liveEta+'m (live)</span>' : '');
const todayCount = Object.keys(d.todayHours || {}).length;
meta.textContent = (todayCount || liveEta != null)
? "Historical points = 15-min collected data; the “now” dot = live ETA."
: "No data yet for today.";
}
// --- Web Push alerts ------------------------------------------------------
const VAPID_PUBLIC = "BAsP8y5UZmnV_9GW8X1N6xejAFiyNN2TmFrBP-IGLxzlzR8yonuEQx1xe_EPiRFu3kztuXuHdLPNfzYJxOB9Hsk";
function urlB64ToUint8Array(b64){
const pad="=".repeat((4-b64.length%4)%4);
const base=(b64+pad).replace(/-/g,"+").replace(/_/g,"/");
const raw=atob(base); const arr=new Uint8Array(raw.length);
for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i);
return arr;
}
const btnEnable=document.getElementById("btnEnable");
const btnTest=document.getElementById("btnTest");
const alertMsg=document.getElementById("alertMsg");
const alertHint=document.getElementById("alertHint");
function alertsSupported(){
return ("serviceWorker" in navigator)&&("PushManager" in window)&&("Notification" in window);
}
async function refreshAlertState(){
if(!alertsSupported()){
alertMsg.textContent="This browser can't do push. On the Honor, install the app (Add to Home Screen) and open it in Chrome, then enable alerts.";
if(btnEnable) btnEnable.disabled=true; return;
}
try{
const reg=await navigator.serviceWorker.ready;
const sub=await reg.pushManager.getSubscription();
if(sub && Notification.permission==="granted"){
btnEnable.textContent="Alerts enabled ✓"; btnEnable.classList.add("ok"); btnEnable.disabled=true;
btnTest.classList.remove("hide");
alertMsg.textContent="Alerts are enabled on this phone.";
alertHint.textContent="Set a rule below to get GO/WAIT buzzes. Keep the app installed (Add to Home Screen).";
document.getElementById("ruleBox").classList.remove("hide");
loadRule();
}else{
btnEnable.textContent="Enable alerts on this phone"; btnEnable.classList.remove("ok"); btnEnable.disabled=false;
btnTest.classList.add("hide");
document.getElementById("ruleBox").classList.add("hide");
}
}catch(e){}
}
if(btnEnable) btnEnable.onclick=async()=>{
try{
btnEnable.disabled=true; alertMsg.textContent="Requesting permission…";
const perm=await Notification.requestPermission();
if(perm!=="granted"){ alertMsg.textContent="Permission denied. Allow notifications for this app in phone settings, then retry."; btnEnable.disabled=false; return; }
const reg=await navigator.serviceWorker.ready;
let sub=await reg.pushManager.getSubscription();
if(!sub) sub=await reg.pushManager.subscribe({userVisibleOnly:true, applicationServerKey:urlB64ToUint8Array(VAPID_PUBLIC)});
const res=await fetch("/api/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({subscription:sub.toJSON(), ua:navigator.userAgent})});
if(!res.ok) throw new Error("save failed ("+res.status+")");
alertMsg.textContent="Alerts enabled on this phone ✓";
await refreshAlertState();
}catch(e){ alertMsg.textContent="Could not enable alerts: "+e.message; btnEnable.disabled=false; }
};
if(btnTest) btnTest.onclick=async()=>{
try{
btnTest.disabled=true; alertHint.textContent="Requesting a test push…";
const res=await fetch("/api/request-test",{method:"POST"});
if(!res.ok) throw new Error("request failed ("+res.status+")");
alertHint.textContent="Test requested ✓ — lock the phone / close the app and wait up to ~3 min for the buzz.";
}catch(e){ alertHint.textContent="Test request failed: "+e.message; }
finally{ setTimeout(()=>{btnTest.disabled=false;},5000); }
};
// --- Alert rule (day/time window + GO/WAIT thresholds) --------------------
const RULE_DAYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
(function buildDayChips(){
const wrap=document.getElementById("ruleDays"); if(!wrap) return;
RULE_DAYS.forEach(d=>{
const el=document.createElement("div"); el.className="rule-day"; el.textContent=d; el.dataset.day=d;
el.onclick=()=>el.classList.toggle("on");
wrap.appendChild(el);
});
})();
function selectedDays(){ return [...document.querySelectorAll(".rule-day.on")].map(e=>e.dataset.day); }
function setDays(days){ document.querySelectorAll(".rule-day").forEach(e=>e.classList.toggle("on",(days||[]).includes(e.dataset.day))); }
function ruleSummaryText(r){
if(!r) return "";
const thr=[]; if(r.go!=null) thr.push("GO ≤ "+r.go+"m"); if(r.wait!=null) thr.push("WAIT ≥ "+r.wait+"m");
return (r.days||[]).join(", ")+" · "+r.from+"–"+r.to+" · "+thr.join(" · ");
}
async function loadRule(){
try{
const r=await fetch("/api/rule",{cache:"no-store"}); const d=await r.json();
const rule=d.rule;
const sum=document.getElementById("ruleSummary");
if(rule){
setDays(rule.days);
document.getElementById("ruleFrom").value=rule.from;
document.getElementById("ruleTo").value=rule.to;
document.getElementById("goOn").checked=rule.go!=null;
if(rule.go!=null) document.getElementById("goVal").value=rule.go;
document.getElementById("waitOn").checked=rule.wait!=null;
if(rule.wait!=null) document.getElementById("waitVal").value=rule.wait;
sum.textContent="Active rule: "+ruleSummaryText(rule);
sum.classList.remove("hide");
} else { sum.classList.add("hide"); }
}catch(e){}
}
if(document.getElementById("btnSaveRule")) document.getElementById("btnSaveRule").onclick=async()=>{
const msg=document.getElementById("ruleMsg");
const body={ days:selectedDays(),
from:document.getElementById("ruleFrom").value, to:document.getElementById("ruleTo").value,
go: document.getElementById("goOn").checked ? parseInt(document.getElementById("goVal").value,10) : null,
wait: document.getElementById("waitOn").checked ? parseInt(document.getElementById("waitVal").value,10) : null };
if(!body.days.length){ msg.innerHTML='<span class="err">Pick at least one day.</span>'; return; }
if(body.go==null && body.wait==null){ msg.innerHTML='<span class="err">Enable a GO and/or WAIT threshold.</span>'; return; }
msg.textContent="Saving…";
try{
const r=await fetch("/api/rule",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
const d=await r.json();
if(!r.ok) throw new Error(d.error||r.status);
msg.textContent="Rule saved ✓ — you'll buzz when it trips (max once / 2h).";
loadRule();
}catch(e){ msg.innerHTML='<span class="err">Save failed: '+e.message+'</span>'; }
};
if(document.getElementById("btnDeleteRule")) document.getElementById("btnDeleteRule").onclick=async()=>{
const msg=document.getElementById("ruleMsg");
try{
const r=await fetch("/api/rule",{method:"DELETE"});
if(!r.ok) throw new Error(r.status);
setDays([]); document.getElementById("goOn").checked=false; document.getElementById("waitOn").checked=false;
document.getElementById("ruleSummary").classList.add("hide");
msg.textContent="Rule deleted — no alerts will fire.";
}catch(e){ msg.innerHTML='<span class="err">Delete failed: '+e.message+'</span>'; }
};
// --- Calibration: log my actual crossing time ----------------------------
const btnStart=document.getElementById("btnStartCross");
const logMsg=document.getElementById("logMsg");
const logCount=document.getElementById("logCount");
let crossTimer=null;
function curQueue(){ const q=(window.__live||{}).queueEstimate||{}; return {bucket:q.bucket||null, score:(q.score!=null?q.score:null)}; }
function elapsedMin(ms){ return Math.max(1, Math.round(ms/60000)); }
function renderStartBtn(){
if(!btnStart) return;
const raw=localStorage.getItem("tuasCrossing");
if(raw){ const c=JSON.parse(raw);
btnStart.textContent="Cleared booths — "+elapsedMin(Date.now()-c.startMs)+" min so far (tap when through)";
btnStart.classList.add("ok");
} else { btnStart.textContent="Start crossing (joining the queue)"; btnStart.classList.remove("ok"); }
}
function showLogCount(n){
if(n==null) return;
const need=Math.max(0,12-n);
logCount.textContent=n+" crossing"+(n===1?"":"s")+" logged"+
(need>0?" · ~"+need+" more to calibrate the bands (need ~10–15)":" · enough to calibrate — bands can now use your real data");
}
async function postLog(payload){
try{ logMsg.textContent="Saving…";
const r=await fetch("/api/crossing-log",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
const d=await r.json(); if(!r.ok) throw new Error(d.error||r.status);
logMsg.innerHTML="Logged ✓ "+payload.minutes+" min"+(payload.bucket?" (camera said "+String(payload.bucket).toUpperCase()+")":"")+". Thanks — this sharpens the estimate.";
showLogCount(d.count);
}catch(e){ logMsg.innerHTML='<span class="err">Log failed: '+e.message+'</span>'; }
}
if(btnStart) btnStart.onclick=async()=>{
const raw=localStorage.getItem("tuasCrossing");
if(!raw){
const q=curQueue();
localStorage.setItem("tuasCrossing",JSON.stringify({startMs:Date.now(),startTs:new Date().toISOString(),bucket:q.bucket,score:q.score}));
renderStartBtn(); if(!crossTimer) crossTimer=setInterval(renderStartBtn,30000);
logMsg.textContent="Crossing started (camera now: "+((q.bucket||"?").toUpperCase())+"). Tap again when you clear the booths.";
} else {
const c=JSON.parse(raw); const minutes=elapsedMin(Date.now()-c.startMs);
await postLog({minutes:minutes,bucket:c.bucket,score:c.score,startTs:c.startTs,endTs:new Date().toISOString(),mode:"timer"});
localStorage.removeItem("tuasCrossing"); if(crossTimer){clearInterval(crossTimer);crossTimer=null;} renderStartBtn();
}
};
if(document.getElementById("btnLogMins")) document.getElementById("btnLogMins").onclick=async()=>{
const v=parseInt(document.getElementById("logMins").value,10);
if(!v||v<1){ logMsg.innerHTML='<span class="err">Enter how many minutes it took.</span>'; return; }
const q=curQueue(); await postLog({minutes:v,bucket:q.bucket,score:q.score,mode:"manual"});
document.getElementById("logMins").value="";
};
async function loadLogCount(){ try{ const d=await (await fetch("/api/crossing-log",{cache:"no-store"})).json(); showLogCount(d.count); }catch(e){} renderStartBtn(); }
loadLive();
setInterval(loadLive, 60000);
loadLogCount();
if("serviceWorker" in navigator){
window.addEventListener("load",()=>navigator.serviceWorker.register("/sw.js")
.then(refreshAlertState).catch(()=>{}));
}
</script>
</body>
</html>