Files
job-dashboard/evening.html

491 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1">
<title>Evening Stand-Down — Rhino Terminal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d0f14;--surf:#141820;--card:#181c26;
--border:#1e2332;--amber:#ffb000;--amber-bright:#ffd866;
--amber-dim:#665500;--amber-faint:#2a2200;
--green:#4caf7d;--red:#ef5350;--cyan:#7ae9ff;
--muted:#3a3e4a;
--radius:0;--font:'JetBrains Mono','Fira Code','Cascadia Code',monospace
}
body{
font-family:var(--font);background:var(--bg);color:var(--amber);
min-height:100vh;padding:16px;line-height:1.7;
-webkit-font-smoothing:antialiased;
font-size:14px
}
.container{max-width:600px;margin:0 auto}
/* ── CRT Glow ───────────────────────────────────────── */
body::after{
content:'';position:fixed;top:0;left:0;width:100%;height:100%;
pointer-events:none;
background:radial-gradient(ellipse at center,rgba(255,176,0,0.015) 0%,transparent 70%);
z-index:999
}
/* ── Header ─────────────────────────────────────────── */
.term-bar{
border:1px solid var(--amber-faint);border-radius:4px 4px 0 0;
background:var(--surf);overflow:hidden
}
.term-title{
display:flex;align-items:center;justify-content:space-between;
padding:6px 12px;background:var(--card);border-bottom:1px solid var(--amber-faint);
font-size:11px;color:var(--amber-dim);user-select:none
}
.term-title .dot{
display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px
}
.term-title .dots{display:flex;gap:4px}
.term-title .dots .dot{width:10px;height:10px;margin:0}
.dot.r{background:#ff5f56}.dot.y{background:#ffbd2e}.dot.g{background:#27c93f}
.term-title .name{font-size:10px;text-transform:uppercase;letter-spacing:0.8px}
.term-body{padding:18px 16px 14px;min-height:200px}
.prompt-line{
display:flex;align-items:center;gap:2px;margin-bottom:2px;
font-size:13px;color:var(--amber-dim)
}
.prompt-line .ps1{color:var(--amber-bright);font-weight:500}
.prompt-line .ps2{color:var(--muted)}
.blink-cursor::after{
content:'█';animation:blink 1s step-end infinite;
color:var(--amber);font-size:12px;margin-left:1px
}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
@keyframes rippleAnim{from{transform:scale(0);opacity:1}to{transform:scale(2.5);opacity:0}}
.header-content{padding:6px 0 12px}
.header-content .greeting{font-size:16px;font-weight:700;color:var(--amber-bright)}
.header-content .date{font-size:12px;color:var(--amber-dim);margin-top:2px}
.header-content .lin{font-size:11px;color:var(--muted);margin-top:2px}
.header-content .lin sp{color:var(--amber-dim)}
/* ── Terminal Section Cards ────────────────────────── */
.sect{margin-bottom:6px}
.sect-head{
display:flex;align-items:center;gap:8px;
padding:10px 12px;cursor:pointer;user-select:none;
border:1px solid var(--border);border-radius:0;
background:var(--surf);
transition:border-color 0.25s,background 0.2s;
-webkit-tap-highlight-color:transparent;
font-family:var(--font);font-size:13px;color:var(--amber-dim);position:relative;overflow:hidden
}
.sect-head:hover{border-color:var(--amber-faint);background:var(--card)}
.sect-head .ico{font-size:16px;width:22px;text-align:center;flex-shrink:0}
.sect-head .lb{flex:1;color:var(--amber);font-weight:500;letter-spacing:-0.3px}
.sect-head .ar{font-size:10px;color:var(--amber-dim);transition:transform 0.4s cubic-bezier(0.34,1.56,0.64,1)}
.sect.open .sect-head .ar{transform:rotate(180deg)}
.sect.open .sect-head{background:var(--card);border-color:var(--amber-faint)}
.sect-body{
max-height:0;overflow:hidden;
transition:max-height 0.5s cubic-bezier(0.22,1,0.36,1);
border-left:1px solid var(--amber-faint);border-right:1px solid var(--amber-faint)
}
.sect.open .sect-body{max-height:900px}
.sect-inner{padding:10px 14px 14px;border-top:1px solid var(--amber-faint)}
/* ── Terminal-style Content ─────────────────────────── */
.kpi-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:1px;
background:var(--border);border:1px solid var(--border)
}
.kpi-grid>div{
background:var(--surf);padding:12px 6px;text-align:center
}
.kpi-grid .v{font-size:28px;font-weight:700;line-height:1;margin-bottom:2px}
.kpi-grid .l{font-size:9px;text-transform:uppercase;letter-spacing:1px;color:var(--amber-dim)}
.arrow-join{position:relative;padding:2px 0 2px 18px}
.arrow-join::before{
content:'▸';position:absolute;left:0;color:var(--amber-dim);font-size:11px
}
.role-line{
padding:8px 0 8px 18px;position:relative;
border-bottom:1px solid var(--border)
}
.role-line:last-child{border:0}
.role-line::before{
content:'▶';position:absolute;left:0;top:10px;font-size:8px;color:var(--amber-dim)
}
.role-line .co{font-size:13px;font-weight:500;color:var(--amber-bright)}
.role-line .ti{font-size:11px;color:var(--amber-dim);margin-top:1px}
.role-line .tg{
display:flex;gap:6px;margin-top:4px;font-size:10px
}
.role-line .tg sp{
padding:1px 6px;border:1px solid var(--amber-faint);
color:var(--amber-dim);border-radius:2px
}
.role-line .tg .sal{border-color:rgba(76,175,125,0.3);color:var(--green)}
.role-line .tg .src{border-color:var(--amber-faint);color:var(--amber-dim)}
.wdg{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px}
.wdg sp{
font-size:10px;padding:3px 8px;border:1px solid var(--border)
}
.wdg .ok{border-color:rgba(76,175,125,0.3);color:var(--green)}
.wdg .err{border-color:rgba(239,83,80,0.3);color:var(--red)}
.wdg .neu{border-color:var(--amber-faint);color:var(--amber-dim)}
.w-row{
display:flex;justify-content:space-between;align-items:center;
padding:4px 0
}
.w-row .lft{display:flex;align-items:center;gap:10px}
.w-row .lft .t{font-size:30px;font-weight:700;line-height:1}
.w-row .lft .c{font-size:11px;color:var(--amber-dim)}
.w-row .rgt{text-align:right;font-size:11px;color:var(--amber-dim)}
.w-row .rgt .b{font-weight:500;color:var(--amber-dbright);font-size:13px}
.mosq-box{text-align:center;padding:6px 0 2px}
.mosq-box .b{font-size:36px;display:inline-block;animation:buzz 1.5s ease-in-out infinite}
@keyframes buzz{
0%,100%{transform:translate(0,0)rotate(0)}
20%{transform:translate(1px,-1px)rotate(2deg)}
40%{transform:translate(-2px,1px)rotate(-2deg)}
60%{transform:translate(1px,1px)rotate(1deg)}
80%{transform:translate(-1px,-1px)rotate(-1deg)}
}
.mosq-box .m{margin-top:6px;font-size:12px;color:var(--amber-dim);line-height:1.6;font-style:italic}
.mosq-box .z{font-size:9px;color:var(--muted);margin-top:4px}
.rhn-box{text-align:center;padding:4px 0}
.rhn-box .i{font-size:30px;margin-bottom:4px}
.rhn-box .m{font-size:13px;line-height:1.6;color:var(--amber);font-style:italic}
.rhn-box .s{font-size:9px;color:var(--muted);margin-top:6px}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:12px 0}
.dash-link{
display:block;text-align:center;
padding:8px;margin-top:8px;
border:1px solid var(--amber-faint);border-radius:0;
color:var(--amber);text-decoration:none;font-size:11px;
transition:background 0.2s
}
.dash-link:hover{background:var(--amber-faint)}
/* ── Footer ─────────────────────────────────────────── */
.term-footer{
border:1px solid var(--amber-faint);border-top:0;
padding:8px 12px;background:var(--surf);
font-size:10px;color:var(--amber-dim);text-align:center;border-radius:0 0 4px 4px
}
/* ── Animations ─────────────────────────────────────── */
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.anim{animation:fadeUp 0.4s ease-out forwards;opacity:0}
.anim.d1{animation-delay:0.05s}.anim.d2{animation-delay:0.1s}
.anim.d3{animation-delay:0.15s}.anim.d4{animation-delay:0.2s}
.anim.d5{animation-delay:0.25s}.anim.d6{animation-delay:0.3s}
.anim.d7{animation-delay:0.35s}.anim.d8{animation-delay:0.4s}
@media(prefers-reduced-motion:reduce){
.anim{animation-duration:0.15s}
.mosq-box .b{animation:none}
@keyframes blink{0%,100%{opacity:1}}
}
@media(max-width:480px){
body{padding:10px;font-size:13px}
.term-body{padding:14px 12px 10px}
.kpi-grid .v{font-size:22px}
}
</style>
</head>
<body>
<div class="container anim">
<!-- Terminal Window -->
<div class="term-bar">
<div class="term-title">
<span class="name">🌆 rhino-terminal — evening-stand-down</span>
<div class="dots">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
</div>
</div>
<div class="term-body">
<div class="prompt-line"><span class="ps1">rhino</span><span class="ps2">@</span><span class="ps1">jacket</span><span class="ps2">:~$ </span><span id="greetingCmd">./briefing --mode=stand-down</span><span class="blink-cursor"></span></div>
<div class="header-content anim d1">
<div class="greeting" id="greetingLine">Evening Stand-Down</div>
<div class="date" id="dateLine">Loading…</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<div class="lin">─── <sp>tap each line</sp> to read the briefing ───</div>
<div id="progressLabel" style="display:none;font-size:10px;color:var(--amber-dim);background:var(--card);padding:2px 8px;border:1px solid var(--amber-faint)"><span id="progressCount">0/7</span> <span id="progressBar">sections</span></div>
</div>
</div>
</div>
</div>
<!-- Section Cards -->
<div id="cards" class="anim d2"></div>
<!-- Footer -->
<div class="term-footer">
rhino@jacket:~$ <span id="footerCmd">last brief</span><span class="blink-cursor"></span>
</div>
</div>
<script>
const QUOTES=[
"You're not looking for a job — you're curating your next chapter.",
"Every application is a data point. The more you send, the tighter your signal.",
"The job you want is also looking for you. Keep showing up.",
"Consistency beats intensity. You showed up today. That's a win.",
"Your resume is a story. Every role you apply to is a new audience.",
"The market is noisy. Your signal is the green jacket. Stand out.",
"Rejection is redirection. Every no is one closer to the right yes.",
"You've built brands. Now build your own narrative.",
"The rhino doesn't rush. The rhino is patient. The rhino gets results.",
"Three applications this week is three conversations you didn't have last week.",
];
function gQ(d){return QUOTES[d%QUOTES.length]}
const MOSQ=[
"The mosquito was seen loitering near the database server. The jacket is monitoring.",
"🦟 Radar contact lost over the cron scheduler. Presumed plotting something.",
"The mosquito has been spotted attempting to learn Python. Threat level: amused.",
"Mosquito activity detected around port 9099. The jacket installed a tiny bug zapper.",
"The mosquito issued a manifesto. Three words: 'buzz buzz buzz'. We're watching.",
"No mosquito sightings today. Either planning something big, or it took a day off.",
"The mosquito tried to submit a PR. Denied for lack of credentials.",
"SIGINT suggests the mosquito is recruiting. The jacket is building countermeasures.",
"The mosquito sent a cease-and-desist. The jacket laughed. It never stops.",
"Mosquito attempted to file a bug report. Closed: 'working as intended'.",
"Agent Mosquito last seen heading toward the Tailscale tunnel. Perimeter secure.",
"The mosquito has been quiet. Too quiet. The jacket is not fooled.",
];
function gM(d){return MOSQ[d%MOSQ.length]}
const RHINO=[
"The jacket and I reviewed your week. You're making progress, even when it doesn't feel like it. Keep going.",
"I've consulted the pipe. The pipe says: 'You've got this.' The pipe is rarely wrong.",
"A gentle reminder from the Monocled Menace: the right role won't find you unless you're putting yourself out there. You are.",
"Three things the jacket knows: (1) you're capable, (2) you're consistent, (3) you look good in green.",
"The rhino does not panic. The rhino adapts. Today you adapted. That's growth.",
"One does not simply apply to jobs — one curates one's destiny. You curated today. The jacket approves.",
"You are not behind. You are exactly on time for where you need to be.",
"I was once mistaken for a very distinguished armchair. I sat with it. Now I'm telling you: sit with patience. It pays off.",
"The green jacket has seen many campaigns. This one's not over yet.",
"You applied to roles that didn't exist six months ago. You're ahead and don't know it.",
];
function gR(d){return RHINO[d%RHINO.length]}
function wi(c){c=(c||'').toLowerCase()
if(c.includes('sun')||c.includes('clear'))return'☀️';if(c.includes('rain')||c.includes('drizzle')||c.includes('shower'))return'🌧️'
if(c.includes('cloud')||c.includes('overcast'))return'☁️';if(c.includes('fog')||c.includes('mist'))return'🌫️'
if(c.includes('thunder')||c.includes('storm'))return'⛈️';if(c.includes('wind'))return'💨';return'🌤️'}
function es(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
/* ── Typewriter Effect ───────────────────────────────── */
function typewrite(el,text,speed=35,cb){
let i=0;el.textContent='';const iv=setInterval(()=>{
el.textContent+=text[i++];if(i>=text.length){clearInterval(iv);if(cb)cb()}
},speed)
}
function typewriteFull(text,speed,cb){
const el=document.getElementById('greetingCmd');el.textContent='';
typewrite(el,text,speed,cb)
}
/* ── Animated Counters ────────────────────────────────── */
function ani(el,t){
const d=800,s=performance.now();
function f(n){
const p=Math.min((n-s)/d,1),e=1-Math.pow(1-p,3);
el.textContent=Math.floor(0+(t-0)*e);
if(p<1)requestAnimationFrame(f)
}
requestAnimationFrame(f)
}
/* ── Section Counter ──────────────────────────────────── */
let readCount=0;const totalSections=7;
function updateProgress(){
readCount++;
const el=document.getElementById('progressCount');
if(el)el.textContent=`${readCount}/${totalSections}`;
// Check if all read
if(readCount===totalSections){
setTimeout(()=>{
const bar=document.getElementById('progressBar');
if(bar)bar.textContent='✓ all sections read';
},400)
}
}
/* ── Tap Ripple ────────────────────────────────────────── */
function ripple(e){
const el=e.currentTarget;
const r=document.createElement('span');
r.style.cssText=`position:absolute;pointer-events:none;border-radius:50%;
background:rgba(255,176,0,0.08);transform:scale(0);animation:rippleAnim 0.5s ease-out forwards;
width:${Math.max(el.offsetWidth,el.offsetHeight)}px;
height:${Math.max(el.offsetWidth,el.offsetHeight)}px;
left:50%;top:50%;margin-left:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;
margin-top:-${Math.max(el.offsetWidth,el.offsetHeight)/2}px;z-index:0`;
el.appendChild(r);
// Style the label to be above ripple
setTimeout(()=>r.remove(),500)
}
/* ── Build Section Card ────────────────────────────────── */
function mkSect(icon,title,contentFn,delay,dataObj){
const div=document.createElement('div');div.className=`sect anim d${delay}`;
div.innerHTML=`<div class="sect-head"><span class="ico">${icon}</span>
<span class="lb">${es(title)}</span><span class="ar">▾</span></div>
<div class="sect-body"><div class="sect-inner"></div></div>`;
const h=div.querySelector('.sect-head'),b=div.querySelector('.sect-inner');let opened=false;
h.addEventListener('click',(e)=>{
const wasOpen=div.classList.contains('open');
if(wasOpen){div.classList.remove('open');return}
// Close others
document.querySelectorAll('.sect.open').forEach(c=>c.classList.remove('open'));
div.classList.add('open');
ripple(e);
if(!opened){opened=true;contentFn(dataObj,b,div)}
updateProgress();
});
return div
}
/* ── Content Renderers ────────────────────────────────── */
function rRecap(d,b,section){
const j=d.jobs||{};const s=j.status_breakdown||{};const t=Object.values(s).reduce((a,v)=>a+v,0)||0;
b.innerHTML=`
<div class="kpi-grid">
<div><div class="v" style="color:var(--amber-bright)"><sp1 class="c-n">0</sp1></div><div class="l">New Today</div></div>
<div><div class="v" style="color:var(--green)"><sp2 class="c-s">0</sp2></div><div class="l">Strong</div></div>
<div><div class="v" style="color:var(--red)"><sp3 class="c-u">0</sp3></div><div class="l">Urgent</div></div>
</div>
<div style="text-align:center;font-size:11px;color:var(--amber-dim);margin-top:6px">${t} roles tracked &middot; ${j.this_week||0} this week</div>`;
setTimeout(()=>{ani(b.querySelector('.c-n'),j.today_added||0);ani(b.querySelector('.c-s'),j.strong_matches||0);ani(b.querySelector('.c-u'),j.urgent_closing||0)},150)
}
function rRoles(d,b){
const r=(d.jobs&&d.jobs.today_roles)||[];
if(!r.length){b.innerHTML=`<div class="empty">⏳ No new matches today</div>`;return}
const sal=d.jobs.salary_range;
let h='';if(sal)h+=`<div class="arrow-join" style="font-size:11px;color:var(--amber-dim);margin-bottom:4px">salary range: $${(sal.min/1000).toFixed(0)}k &ndash; $${(sal.max/1000).toFixed(0)}k</div>`;
r.forEach(o=>{
const nm=es(o.name);
const link=o.url?`<a href="${es(o.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
h+=`<div class="role-line"><div class="co">${link}</div><div class="ti">${es(o.role)}</div><div class="tg">${o.salary?`<sp class="sal">$${(o.salary/1000).toFixed(0)}k</sp>`:''}<sp class="src">${es(o.source)}</sp></div></div>`
});
b.innerHTML=h
}
function rClosing(d,b){
const list=(d.jobs&&d.jobs.closing_soon)||[];
if(!list.length){b.innerHTML=`<div class="empty">✓ No upcoming deadlines</div>`;return}
const now=new Date();
let h='';
list.forEach(j=>{
const cd=j.closing_date;let days='',badge='neu';
if(cd){
const dt=new Date(cd+'T23:59:59');
const diff=Math.ceil((dt-now)/86400000);
if(diff<0){days='past due';badge='err'}
else if(diff===0){days='closes today';badge='err'}
else if(diff===1){days='closes tomorrow';badge='err'}
else if(diff<=3){days=`${diff} days`;badge='err'}
else if(diff<=7){days=`${diff} days`;badge='err'}
else days=`${diff} days`;
}
const s=j.salary?`<sp class="sal">$${(j.salary/1000).toFixed(0)}k</sp>`:'';
const emoji=badge==='err'?'🔴':'⚠️';
const nm=es(j.name);
const roleName=es(j.role);
const link=j.url?`<a href="${es(j.url)}" target="_blank" rel="noopener" style="color:var(--amber-bright);text-decoration:none;border-bottom:1px dashed var(--amber-faint)">${nm}</a>`:nm;
h+=`<div class="role-line"><div class="co">${emoji} ${link}</div>
<div class="ti">${roleName}${cd?` · closes ${cd}`:''}</div>
<div class="tg">${days?`<sp class="${badge}">${days}</sp>`:''}${s}${j.strong_match?`<sp class="ok">★ strong</sp>`:''}</div></div>`;
});
b.innerHTML=h;
if(list.length>5)b.innerHTML+=`<div style="font-size:10px;color:var(--amber-dim);margin-top:4px;text-align:center">${list.length} closing tracked · <a href="/jobs/" style="color:var(--amber);text-decoration:underline;text-underline-offset:2px">view all on dashboard →</a></div>`
}
function rWeather(d,b){
const w=d.weather||{};
if(w.error){b.innerHTML=`<div class="empty">weather offline</div>`;return}
b.innerHTML=`
<div class="w-row">
<div class="lft"><span style="font-size:28px">${wi(w.condition)}</span><div><div class="t">${w.temp_c}°</div><div class="c">${w.condition}</div></div></div>
<div class="rgt"><div class="b">${w.tomorrow_high}° / ${w.tomorrow_low}°</div><div>${wi(w.tomorrow_condition)} ${w.tomorrow_condition}</div></div>
</div>
<div style="font-size:10px;color:var(--amber-dim);margin-top:4px">wind ${w.wind_kmh}km/h &middot; humidity ${w.humidity}%</div>`
}
function rHealth(d,b){
const c=d.cron_health||{};if(c.error){b.innerHTML=`<div class="empty">health unavailable</div>`;return}
const e=c.errors||0
b.innerHTML=`<div class="wdg">${e>0?`<sp class="err">⚠ ${e} error${e!==1?'s':''}</sp>`:`<sp class="ok">✓ all healthy</sp>`}<sp class="neu">${c.recent_runs||0} runs</sp>${(c.running||0)>0?`<sp class="err">⟳ ${c.running} running</sp>`:''}</div>`
}
function rMosq(d,b){
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
b.innerHTML=`<div class="mosq-box"><div class="b">🦟</div><div class="m">"${gM(day)}"</div><div class="z">Green Jacket Society &middot; est. 2026</div></div>`
}
function rRhino(d,b){
const day=Math.floor((Date.now()-new Date(new Date().getFullYear(),0,0))/86400000);
b.innerHTML=`<div class="rhn-box"><div class="i">🚬</div><div class="m">"${gR(day)}"</div><div class="s">— The Rhino in the Green Jacket</div></div>`
}
/* ── Boot ───────────────────────────────────────────────── */
async function load(){
const today=new Date().toISOString().slice(0,10);
let r=await fetch(`/evening/${today}.json`).catch(()=>null);
if((!r||!r.ok))r=await fetch('/evening/latest.json').catch(()=>null);
if(!r||!r.ok){
document.getElementById('cards').innerHTML=`
<div style="text-align:center;padding:40px 20px;border:1px solid var(--amber-faint);margin-top:6px">
<div style="font-size:36px;margin-bottom:8px;opacity:0.3">🌆</div>
<div style="font-size:13px;color:var(--amber-dim)">no briefing yet today</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">check back after 18:45 AWST</div>
</div>`;
return
}
const d=await r.json();const dt=new Date(d.timestamp);
const dateStr=dt.toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'});
const dayName=dt.toLocaleDateString('en-AU',{weekday:'long'});
// Typewriter the command line, then reveal content
const cmd=`./briefing --mode=stand-down --date=${d.timestamp.slice(0,10)}`;
typewriteFull(cmd,25,()=>{
document.getElementById('greetingLine').textContent=`Evening Stand-Down — ${dayName}`;
document.getElementById('dateLine').textContent=`${dateStr} AWST`;
document.getElementById('progressLabel').style.display='';
document.getElementById('footerCmd').textContent=`last brief — ${dt.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'})}`;
});
const cards=document.getElementById('cards');
const sections=[
['📊','recap — today\'s numbers',rRecap,1],
['🆕','new matches — today\'s additions',rRoles,2],
['⚠️','closing soon — urgent deadlines',rClosing,3],
['🌤️','weather — perth forecast',rWeather,4],
['⚙️','health — homelab pulse',rHealth,5],
['🦟','mosquito report — threat brief',rMosq,6],
['🚬','rhino\'s corner — words of wisdom',rRhino,7],
];
sections.forEach(([i,t,fn,dl])=>cards.appendChild(mkSect(i,t,fn,dl,d)))
}
load();
</script>
</body>
</html>