#pragma once

const char ESP_DASHBOARD_HTML[] PROGMEM = R"HTML(
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>SASA ESP RFID</title>
  <style>
    :root{--bg:#131416;--top:#282B2F;--panel:#181A1D;--panel3:#202328;--line:#2A2D32;--line2:#343942;--text:#E6EBF2;--text2:#C6CED8;--muted:#8D96A3;--blue:#4797FF;--blue2:#006EFF;--blueHover:#05254D;--blueSoft:rgba(71,151,255,.12);--green:#2FA252;--greenSoft:rgba(47,162,82,.13);--accent:#9568B8;--red:#EE6368;--redDark:#5C1F21;--yellow:#FFB84C;--r:6px;--r2:8px;--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}
    *{box-sizing:border-box}html,body{min-height:100%}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font);font-size:12px;line-height:1.35;font-weight:400;overflow-x:hidden;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}button,input,textarea,select{font-family:inherit;font-size:12px}::selection{background:rgba(71,151,255,.32)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:#30343A;border-radius:4px;border:2px solid var(--bg)}::-webkit-scrollbar-thumb:hover{background:#3B414A}
    .app{min-height:100vh;display:grid;grid-template-rows:auto 1fr;background:var(--bg)}.topbar{height:44px;display:grid;grid-template-columns:300px minmax(0,1fr) auto;align-items:center;gap:12px;padding:0 12px;background:var(--top);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:9px;min-width:0}.mark{width:22px;height:22px;display:grid;place-items:center;border-radius:var(--r);background:var(--blue2);color:white;font-weight:560;font-size:12px;letter-spacing:-.03em;flex:0 0 auto}.brandText{min-width:0}.brandText h1{margin:0;font-size:13px;line-height:1.1;color:#F3F6FA;font-weight:520;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.brandText .sub{margin-top:2px;font-size:11px;color:#AAB2BD;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tabs{display:flex;align-items:center;justify-content:center;gap:2px;min-width:0;overflow:auto;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.tab{height:30px;padding:0 13px;border:0;border-radius:var(--r);background:transparent;color:#C7CDD6;cursor:pointer;font-weight:450;white-space:nowrap}.tab:hover{background:rgba(5,37,77,.75);color:#fff}.tab.active{background:#34383E;color:#fff}.topMeta{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0}.stamp{color:#B5BDC8;font-size:11px;white-space:nowrap}.status{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 9px;border-radius:var(--r);border:1px solid #464B53;background:rgba(19,20,22,.62);color:#E5EAF1;font-size:11px;font-weight:450;white-space:nowrap;text-transform:capitalize}.dot{width:7px;height:7px;border-radius:50%;background:var(--yellow)}.ok{color:#D9FFE8;background:rgba(47,162,82,.16);border-color:rgba(47,162,82,.38)}.ok .dot{background:var(--green)}.bad{color:#FFD8DF;background:rgba(92,31,33,.75);border-color:rgba(238,99,104,.42)}.bad .dot{background:var(--red)}
    .stream{height:28px;display:inline-flex;align-items:center;gap:7px;padding:0 8px;border-radius:var(--r);border:1px solid #464B53;background:rgba(19,20,22,.48);color:#B5BDC8;font-size:11px;font-weight:450;white-space:nowrap}.streamDot{width:6px;height:6px;border-radius:50%;background:var(--yellow)}.stream.live{color:#D9FFE8;border-color:rgba(47,162,82,.28);background:rgba(47,162,82,.09)}.stream.live .streamDot{background:var(--green);animation:pulse 1.35s ease-in-out infinite}.stream.bad{color:#FFD8DF;border-color:rgba(238,99,104,.32);background:rgba(92,31,33,.36)}.stream.bad .streamDot{background:var(--red)}@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.72)}}
    .layout{width:100%;min-height:calc(100vh - 44px);display:grid;grid-template-columns:300px minmax(0,1fr);background:var(--bg)}.side{min-width:0;background:var(--bg);padding:10px}.sideInner{position:sticky;top:54px;display:grid;gap:10px}.panel,.card{background:transparent;border:1px solid var(--line);border-radius:var(--r2);overflow:hidden;min-width:0}.readerHead{padding:11px;display:flex;align-items:center;gap:10px;background:transparent}.readerIcon{width:32px;height:32px;border-radius:var(--r);background:#20242A;border:1px solid #303640;color:var(--blue);display:grid;place-items:center;flex:0 0 auto}.readerIcon svg{width:19px;height:19px}.readerTitle{min-width:0;flex:1}.readerTitle strong{display:block;color:#F3F6FA;font-size:13px;font-weight:520;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.readerTitle span{display:block;margin-top:2px;color:var(--muted);font-size:11px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.readerRows,.sideSection{padding:10px 11px;display:grid;gap:8px}.kv{display:grid;grid-template-columns:1fr auto;align-items:center;gap:12px;min-width:0}.kv .k{color:#EEF2F7;font-weight:450;min-width:0}.kv .v{color:var(--text2);text-align:right;max-width:154px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:400}.v.blue{color:var(--blue)}.v.green{color:var(--green)}.v.red{color:var(--red)}.v.yellow{color:#DFA446}.v.purple{color:var(--accent)}.sideDivider{height:1px;background:var(--line);margin:2px 0}.sideActions{display:grid;gap:7px;padding:0 11px 11px}.sideActions button{width:100%}.sideLabel{display:flex;align-items:center;justify-content:space-between;color:#EEF2F7;font-weight:520;font-size:12px;padding-bottom:2px}.sideLabel span:last-child{color:var(--muted);font-size:11px;font-weight:400}
    .main{min-width:0;padding:10px;display:grid;align-content:start;gap:10px;background:var(--bg)}.notice{border-radius:var(--r);padding:9px 11px;border:1px solid var(--line2);background:transparent;color:#DCE5EF;font-weight:400}.notice.info{background:var(--blueSoft);border-color:rgba(71,151,255,.28);color:#CFE8FF}.notice.error{background:var(--redDark);border-color:rgba(238,99,104,.42);color:#FFD7DF}.notice.success{background:var(--greenSoft);border-color:rgba(47,162,82,.36);color:#D9FFE8}.hidden{display:none!important}.contentGrid{display:grid;grid-template-columns:minmax(0,.88fr) minmax(460px,1.12fr);gap:10px;align-items:start}.fullGrid{display:grid;grid-template-columns:1fr;gap:10px}#dash{display:grid;gap:18px}.cardHead{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;background:transparent}.cardHead h3{margin:0;color:#F3F6FA;font-size:12px;font-weight:500}.cardHead .hint{color:var(--muted);font-size:11px;font-weight:400;white-space:nowrap}.tableWrap{overflow:auto}.table{width:100%;border-collapse:collapse;font-size:12px}.table th,.table td{text-align:left;padding:8px 12px;border-bottom:1px solid rgba(42,45,50,.72);vertical-align:middle;height:32px}.table tr:last-child th,.table tr:last-child td{border-bottom:0}.table th{width:34%;color:#AEB6C2;font-size:11px;font-weight:450;background:transparent;white-space:nowrap}.table td{color:var(--text2);font-weight:400;word-break:break-word}.table tr:hover td,.table tr:hover th{background:rgba(255,255,255,.018)}.compactTable th{width:auto}.compactTable td,.compactTable th{white-space:nowrap}.actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap;padding:10px 11px;border-top:1px solid var(--line);background:transparent}
    .table tr.flash td,.table tr.flash th{animation:rowFlash .85s ease-out}@keyframes rowFlash{0%{background:rgba(71,151,255,.16)}100%{background:transparent}}
    button{border:1px solid rgba(71,151,255,.72);background:transparent;color:var(--blue);border-radius:var(--r);padding:7px 10px;min-height:30px;cursor:pointer;font-weight:450;transition:background .12s ease,border-color .12s ease,color .12s ease}button:hover{background:var(--blueHover);border-color:var(--blue);color:#DCEBFF}button.primary{background:transparent;border-color:rgba(71,151,255,.86);color:var(--blue)}button.primary:hover{background:var(--blueHover);border-color:var(--blue);color:#fff}button.danger{background:transparent;border-color:rgba(238,99,104,.68);color:#FFA2A6}button.danger:hover{background:var(--redDark);border-color:var(--red);color:#FFE2E4}button:disabled{opacity:.55;cursor:not-allowed}.logoutBtn{min-height:28px;padding:0 9px;border-color:#464B53;color:#C7CDD6}.logoutBtn:hover{background:rgba(255,255,255,.05);border-color:#5A626D;color:#fff}
    .formBody{padding:11px}.formGrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}label{display:block;color:#E6ECF4;font-weight:450;font-size:12px;margin-bottom:10px}label .labelText{display:block;margin-bottom:5px}input,textarea,select{width:100%;border:1px solid #343942;background:#111214;color:#E9EEF6;border-radius:var(--r);padding:8px 9px;min-height:32px;outline:none;font-weight:400}input:focus,textarea:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(71,151,255,.12)}textarea{min-height:76px;resize:vertical}.checkGrid{display:grid;grid-template-columns:repeat(2,minmax(190px,1fr));gap:8px;margin-top:2px}.row{margin:0;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--line);background:transparent;border-radius:var(--r);padding:9px 10px;color:#D7E0EC;font-weight:400;cursor:pointer;user-select:none}.row input[type="checkbox"]{appearance:none;-webkit-appearance:none;width:34px;height:18px;min-height:18px;flex:0 0 34px;margin:0;padding:0;border:1px solid #41474F;border-radius:999px;background:#252A30;cursor:pointer;position:relative;transition:background .14s ease,border-color .14s ease}.row input[type="checkbox"]::before{content:"";position:absolute;width:14px;height:14px;left:1px;top:1px;border-radius:50%;background:#9AA3AE;transition:transform .14s ease,background .14s ease}.row input[type="checkbox"]:checked{background:var(--blue2);border-color:var(--blue)}.row input[type="checkbox"]:checked::before{transform:translateX(16px);background:#fff}.muted{color:var(--muted)}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#DCEFFF;word-break:break-all}
    @media(max-width:1180px){.topbar{grid-template-columns:260px minmax(0,1fr) auto}.layout{grid-template-columns:260px minmax(0,1fr)}.contentGrid{grid-template-columns:1fr}}@media(max-width:860px){.topbar{height:auto;min-height:44px;grid-template-columns:1fr;gap:8px;padding:9px 10px;position:relative}.tabs{justify-content:flex-start;order:3}.topMeta{justify-content:flex-start;flex-wrap:wrap}.layout{display:block}.sideInner{position:static}.formGrid,.checkGrid{grid-template-columns:1fr}}@media(max-width:560px){.table th,.table td{padding:9px 10px}.table th{width:42%}.actions{display:grid}.actions button{width:100%}}
  </style>
</head>
<body>
  <div class="app">
    <header class="topbar"><div class="brand"><div class="mark">S</div><div class="brandText"><h1>SASA ESP RFID</h1><div class="sub" id="subtitle">Loading reader state...</div></div></div><nav class="tabs"><button class="tab active" data-tab="dash">Dashboard</button><button class="tab" data-tab="setup">Setup</button></nav><div class="topMeta"><div id="updatedAt" class="stamp">Not synced yet</div><div id="streamPill" class="stream"><span class="streamDot"></span><span>Connecting</span></div><div id="statePill" class="status"><span class="dot"></span><span>Loading</span></div><button class="logoutBtn" onclick="logout()">Logout</button></div></header>
    <div class="layout"><aside class="side"><div class="sideInner"><div class="panel"><div class="readerHead"><div class="readerIcon"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="6" y="3.5" width="12" height="17" rx="2.5" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><path d="M9 7h6M9 17h6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div><div class="readerTitle"><strong id="sideName">SASA Reader</strong><span id="sideId">Loading device...</span></div></div><div class="readerRows"><div class="kv"><span class="k">Status</span><span id="sideStatus" class="v blue">-</span></div><div class="kv"><span class="k">WiFi</span><span id="sideWifi" class="v">-</span></div><div class="kv"><span class="k">IP Address</span><span id="sideIp" class="v blue">-</span></div><div class="kv"><span class="k">RSSI</span><span id="sideRssi" class="v">-</span></div><div class="sideDivider"></div><div class="kv"><span class="k">Provisioning</span><span id="sideProv" class="v">-</span></div><div class="kv"><span class="k">Mode</span><span id="sideMode" class="v">-</span></div><div class="kv"><span class="k">Clock</span><span id="sideClock" class="v">-</span></div><div class="kv"><span class="k">Heartbeat</span><span id="sideBeat" class="v">-</span></div><div class="sideDivider"></div><div class="kv"><span class="k">Last UID</span><span id="sideUid" class="v purple">-</span></div><div class="kv"><span class="k">Write Job</span><span id="sideJob" class="v">-</span></div><div class="kv"><span class="k">Writing</span><span id="sideWriting" class="v">-</span></div></div><div class="sideActions"><button class="primary" onclick="apiPost('/api/register')">Register / Re-register</button><button onclick="apiPost('/api/sync-time')">Sync Time</button><button onclick="apiPost('/api/poll-job')">Poll Write Job</button></div></div><div class="panel"><div class="sideSection"><div class="sideLabel"><span>Reader</span><span>Local</span></div><div class="kv"><span class="k">Type</span><span id="sideType" class="v">-</span></div><div class="kv"><span class="k">Firmware</span><span id="sideFirmware" class="v">-</span></div><div class="kv"><span class="k">Location</span><span id="sideLocation" class="v">-</span></div></div></div><div class="panel"><div class="sideSection"><div class="sideLabel"><span>Backend</span><span>API</span></div><div class="kv"><span class="k">Server</span><span id="sideServer" class="v blue">-</span></div><div class="kv"><span class="k">TLS</span><span id="sideTls" class="v">-</span></div></div></div></div></aside>
      <main class="main"><div id="notice" class="notice info hidden"></div>
        <section id="dash" class="tabPanel"><div class="contentGrid"><div class="card"><div class="cardHead"><h3>Reader Status</h3><span class="hint" id="statusHint">Current state</span></div><div class="tableWrap"><table class="table"><tbody id="statusRows"></tbody></table></div></div><div class="card"><div class="cardHead"><h3>Current Work</h3><span class="hint" id="workHint">RFID activity</span></div><div class="tableWrap"><table class="table"><tbody id="workRows"></tbody></table></div><div class="actions"><button class="primary" onclick="apiPost('/api/register')">Register / Re-register</button><button onclick="apiPost('/api/sync-time')">Sync Time Now</button></div></div></div><div class="fullGrid"><div class="card"><div class="cardHead"><h3>Active Write Job</h3><span class="hint">Queue</span></div><div class="tableWrap"><table class="table compactTable"><thead><tr><th>Waiting</th><th>Job ID</th><th>Label</th><th>User</th><th>Payload</th><th>Claimed</th></tr></thead><tbody id="jobRows"></tbody></table></div><div class="actions"><button onclick="apiPost('/api/poll-job')">Poll Now</button><button class="danger" onclick="apiPost('/api/cancel-local-job')">Cancel Local Job</button></div></div><div class="card"><div class="cardHead"><h3>Last RFID Activity</h3><span class="hint">Last 10 card events</span></div><div class="tableWrap"><table class="table compactTable"><thead><tr><th>When</th><th>UID</th><th>Action</th><th>Result</th></tr></thead><tbody id="rfidRows"></tbody></table></div></div></div></section>
        <section id="setup" class="tabPanel hidden"><form id="configForm" class="card"><div class="cardHead"><h3>Configuration</h3><span class="hint">Saved on ESP</span></div><div class="formBody"><div class="formGrid"><div><label><span class="labelText">Backend Base URL</span><input name="apiBaseUrl" required></label><label><span class="labelText">Device ID</span><input name="deviceId" required></label><label><span class="labelText">Reader Type</span><input name="readerType" required></label><label><span class="labelText">Reader Name</span><input name="readerName"></label><label><span class="labelText">Location</span><input name="readerLocation"></label></div><div><label><span class="labelText">WiFi SSID <span class="muted">(loaded only on request)</span></span><input name="wifiSsid" placeholder="Click Load WiFi Settings"></label><label><span class="labelText">WiFi Password <span class="muted">(leave blank to keep current)</span></span><input name="wifiPassword" type="password" placeholder="Not shown"></label><label><span class="labelText">Dashboard Username <span class="muted">(leave blank to keep current)</span></span><input name="dashboardUser" autocomplete="off" placeholder="Not shown"></label><label><span class="labelText">Dashboard Password <span class="muted">(leave blank to keep current)</span></span><input name="dashboardPassword" type="password" autocomplete="new-password" placeholder="Not shown"></label><label><span class="labelText">API Key <span class="muted">(leave blank to keep current/provisioned key)</span></span><input name="apiKey" type="password" autocomplete="off" placeholder="Not shown"></label></div></div><label><span class="labelText">Notes</span><textarea name="notes"></textarea></label><div class="checkGrid"><label class="row">Can write RFID cards <input type="checkbox" name="canWriteCards"></label><label class="row">Use TLS <input type="checkbox" name="useTls"></label><label class="row">Allow insecure TLS/self-signed certificate <input type="checkbox" name="tlsInsecure"></label><label class="row">Send tapped_at from synced clock <input type="checkbox" name="sendTappedAt"></label></div></div><div class="actions"><button class="primary" type="submit">Save Configuration</button><button type="button" onclick="loadWifiConfig()">Load WiFi Settings</button><button type="button" onclick="apiPost('/api/register')">Register Reader</button><button type="button" onclick="apiPost('/api/clear-provisioning')">Clear Provisioning</button><button class="danger" type="button" onclick="apiPost('/api/reboot')">Reboot</button></div></form></section>
      </main></div></div>
<script>
let latest=null,statusBusy=false,statusTimer=null,activeTab='dash',consecutiveStatusFailures=0,wifiConfigLoaded=false;
let eventSource=null,streamConnected=false,fallbackPolling=true,lastStatusAt=0,lastHistoryKey='';
const ACTIVE_REFRESH_MS=3500,HIDDEN_REFRESH_MS=9000,STATUS_TIMEOUT_MS=3200,ACTION_TIMEOUT_MS=14000;

function $(id){return document.getElementById(id)}
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function row(k,v){return `<tr><th>${esc(k)}</th><td>${esc(v||'-')}</td></tr>`}
function notice(text,type='info'){const n=$('notice');n.className='notice '+type;n.textContent=text;n.classList.remove('hidden');clearTimeout(notice._t);notice._t=setTimeout(()=>n.classList.add('hidden'),5000)}
function setUpdated(source=''){const suffix=streamConnected?' · live':'';$('updatedAt').textContent=(source?source+' ':'')+new Date().toLocaleTimeString()+suffix}
async function requestWithTimeout(path,options={},timeoutMs=STATUS_TIMEOUT_MS){const controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),timeoutMs);try{return await fetch(path,{cache:'no-store',...options,signal:controller.signal})}finally{clearTimeout(timeout)}}
function yesNo(v){return v?'Yes':'No'}
function enabledDisabled(v){return v?'Enabled':'Disabled'}

function setStream(text,tone=''){
  const p=$('streamPill');
  p.className='stream '+tone;
  p.querySelector('span:last-child').textContent=text;
}

function relLocal(localMs){
  if(!(latest?.runtime?.deviceMillis>=0)||!(localMs>=0))return 'Not available';
  const eventClientMs=Date.now()-(latest.runtime.deviceMillis-localMs);
  const diff=Math.max(0,Math.round((Date.now()-eventClientMs)/1000));
  if(diff<5)return 'just now';
  if(diff<60)return `${diff}s ago`;
  if(diff<3600)return `${Math.round(diff/60)}m ago`;
  return `${Math.round(diff/3600)}h ago`;
}

function formatClock(s){
  if(!s.time?.synced)return 'Not synced';
  return `In sync · ${relLocal(s.time.lastSyncLocalMs)}`;
}

function formatHeartbeat(s){
  if(!(s.heartbeat?.lastOkMs>=0)||!s.heartbeat?.lastOk)return 'Waiting for first heartbeat';
  return `Connected ${relLocal(s.heartbeat.lastOkMs)}`;
}

function headlineStatus(s){
  const prov=s.provisioning?.status||'unregistered';
  if(prov==='unregistered')return{tone:'bad',label:'Not registered',detail:'Complete setup and register this reader.'};
  if(prov==='pending')return{tone:'',label:'Awaiting approval',detail:'Registration sent. Waiting for approval from the main dashboard.'};
  if(prov==='rejected')return{tone:'bad',label:'Registration rejected',detail:'Clear provisioning and register again after review.'};
  if(!s.wifi?.connected)return{tone:'bad',label:'Offline',detail:'Reader is not connected to WiFi.'};
  if(s.writeJob?.active)return{tone:'ok',label:'Ready to write',detail:`Waiting for card to write ${s.writeJob.label||'the queued job'}.`};
  return{tone:'ok',label:'Ready',detail:'Reader is online and ready for taps.'};
}

function modeDetail(s){
  const prov=s.provisioning?.status||'unregistered';
  if(prov==='unregistered')return 'Not registered';
  if(prov==='pending')return 'Awaiting approval';
  if(prov==='rejected')return 'Rejected';
  if(!s.wifi?.connected)return 'Offline';
  if(s.writeJob?.active)return 'Card writing';
  return 'Operational';
}

function lastActivity(s){
  const h=s.rfid?.history||[];
  if(h.length){
    const e=h[0];
    return [e.result,e.action,e.uid].filter(Boolean).join(' · ');
  }
  return 'No recent card activity';
}

function setTone(el,tone){
  el.classList.remove('green','red','yellow','blue','purple');
  if(tone)el.classList.add(tone);
}

function refreshDelay(){return document.hidden?HIDDEN_REFRESH_MS:ACTIVE_REFRESH_MS}

function scheduleStatus(){
  clearTimeout(statusTimer);
  if(!fallbackPolling)return;
  statusTimer=setTimeout(loadStatus,refreshDelay());
}

function applyStatus(data,source=''){
  latest=data;
  lastStatusAt=Date.now();
  consecutiveStatusFailures=0;
  render(latest);
  setUpdated(source);
}

async function loadStatus(){
  if(statusBusy){scheduleStatus();return}
  statusBusy=true;
  try{
    const r=await requestWithTimeout('/api/status',{},STATUS_TIMEOUT_MS);
    if(r.status===401){window.location.href='/login';return}
    if(!r.ok)throw new Error(await r.text());
    applyStatus(await r.json(),fallbackPolling?'polled':'');
  }catch(e){
    consecutiveStatusFailures++;
    if(consecutiveStatusFailures>=3){
      setStream('Offline','bad');
      notice(e.name==='AbortError'?'Status failed: request timed out':('Status failed: '+e.message),'error');
    }
  }finally{
    statusBusy=false;
    scheduleStatus();
  }
}

function addRfidEvent(item){
  if(!latest){
    loadStatus();
    return;
  }

  if(!latest.rfid)latest.rfid={history:[]};
  if(!Array.isArray(latest.rfid.history))latest.rfid.history=[];

  const exists=latest.rfid.history.some(e=>
    e.localMs===item.localMs &&
    e.uid===item.uid &&
    e.action===item.action &&
    e.result===item.result
  );

  if(!exists){
    latest.rfid.history.unshift(item);
    latest.rfid.history=latest.rfid.history.slice(0,10);
  }

  latest.rfid.lastInteractionMs=item.localMs;
  lastStatusAt=Date.now();
  render(latest);
  setUpdated('card');
}

function render(s){
  const status=headlineStatus(s),readerName=s.config.readerName||'SASA Reader',deviceId=s.config.deviceId||'Unknown device',wifiText=s.wifi.connected?'Online':'Offline',wifiDetail=s.wifi.connected?`${s.wifi.ip||'-'} · ${s.wifi.rssi??'-'} dBm`:'Disconnected',writeJobText=s.writeJob.active?'#'+s.writeJob.id+' · '+(s.writeJob.label||'Write job'):'None';

  $('subtitle').textContent=`${readerName} · ${deviceId}`;

  const pill=$('statePill');
  pill.className='status '+status.tone;
  pill.querySelector('span:last-child').textContent=status.label;

  $('sideName').textContent=readerName;
  $('sideId').textContent=deviceId;
  $('sideStatus').textContent=status.label;
  $('sideWifi').textContent=wifiText;
  $('sideIp').textContent=s.wifi.connected?(s.wifi.ip||'-'):(s.wifi.apStarted&&s.wifi.apIp?s.wifi.apIp:'-');
  $('sideRssi').textContent=s.wifi.connected?((s.wifi.rssi??'-')+' dBm'):'-';
  $('sideProv').textContent=s.provisioning?.status||'unregistered';
  $('sideMode').textContent=modeDetail(s);
  $('sideClock').textContent=s.time?.synced?'Synced':'Not synced';
  $('sideBeat').textContent=formatHeartbeat(s);
  $('sideUid').textContent=s.rfid?.history?.[0]?.uid||'None';
  $('sideJob').textContent=s.writeJob?.active?('#'+s.writeJob.id):'None';
  $('sideWriting').textContent=enabledDisabled(s.config?.canWriteCards);
  $('sideType').textContent=s.config?.readerType||'-';
  $('sideFirmware').textContent=s.config?.firmwareVersion||'-';
  $('sideLocation').textContent=s.config?.readerLocation||'-';
  $('sideServer').textContent=s.config?.apiBaseUrl||'-';
  $('sideTls').textContent=s.config?.useTls?(s.config?.tlsInsecure?'TLS insecure':'TLS enabled'):'Disabled';

  setTone($('sideStatus'),status.tone==='ok'?'green':status.tone==='bad'?'red':'yellow');
  setTone($('sideWifi'),s.wifi.connected?'green':'red');
  setTone($('sideProv'),status.tone==='ok'?'green':status.tone==='bad'?'red':'yellow');
  setTone($('sideClock'),s.time?.synced?'green':'yellow');
  setTone($('sideWriting'),s.config?.canWriteCards?'green':'yellow');

  $('statusHint').textContent=status.label;
  $('workHint').textContent=s.writeJob?.active?'Write job active':'No queued job';

  $('statusRows').innerHTML=[
    row('Status',status.detail),
    row('Setup AP',s.wifi?.apStarted?`${s.wifi.apSsid||'Started'} · ${s.wifi.apIp||'-'}`:'Off'),
    row('Server',s.config.apiBaseUrl),
    row('Clock',formatClock(s)),
    row('Free heap',s.runtime?.freeHeap?`${s.runtime.freeHeap} bytes`:'-'),
    row('Card writing enabled',yesNo(s.config.canWriteCards)),
    row('API key stored',yesNo(s.provisioning.hasApiKey))
  ].join('');

  $('workRows').innerHTML=[
    row('Reader state',modeDetail(s)),
    row('Mode message',s.runtime?.message||'-'),
    row('Queued write job',writeJobText),
    row('Last card seen',s.rfid?.history?.[0]?.uid||'None'),
    row('Last activity',lastActivity(s))
  ].join('');

  $('jobRows').innerHTML=`<tr><td>${s.writeJob.active?'Yes':'No'}</td><td>${esc(s.writeJob.id||'None')}</td><td>${esc(s.writeJob.label||'None')}</td><td>${esc(s.writeJob.userName||'None')}</td><td>${s.writeJob.payloadLength?esc(s.writeJob.payloadLength+' bytes'):'None'}</td><td>${s.writeJob.claimedMs?esc(relLocal(s.writeJob.claimedMs)):'None'}</td></tr>`;

  const hist=s.rfid?.history||[];
  const historyKey=hist.map(e=>`${e.localMs}:${e.uid}:${e.action}:${e.result}`).join('|');
  const flashNewest=historyKey&&lastHistoryKey&&historyKey!==lastHistoryKey;
  lastHistoryKey=historyKey;

  $('rfidRows').innerHTML=hist.length?hist.map((e,i)=>`<tr class="${flashNewest&&i===0?'flash':''}"><td>${esc(relLocal(e.localMs))}</td><td><code>${esc(e.uid||'-')}</code></td><td>${esc(e.action||'-')}</td><td>${esc(e.result||'-')}</td></tr>`).join(''):`<tr><td colspan="4" class="muted">No RFID activity yet</td></tr>`;

  const f=document.forms.configForm;
  if(!f.dataset.loaded){
    for(const [k,v]of Object.entries(s.config)){
      if(f.elements[k]&&typeof v!=='boolean')f.elements[k].value=v??'';
    }
    ['canWriteCards','useTls','tlsInsecure','sendTappedAt'].forEach(k=>{
      if(f.elements[k])f.elements[k].checked=!!s.config[k];
    });
    f.elements.wifiSsid.value='';
    f.dataset.loaded='1';
  }
}

async function apiPost(path,body={}){
  try{
    const r=await requestWithTimeout(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)},ACTION_TIMEOUT_MS);
    const t=await r.text();
    if(r.status===401){window.location.href='/login';return}
    if(!r.ok)throw new Error(t);
    notice(t||'Done','success');
    await loadStatus();
  }catch(e){
    notice(e.name==='AbortError'?'Request timed out':e.message,'error');
  }
}

async function loadWifiConfig(){
  try{
    const r=await requestWithTimeout('/api/wifi-config',{},ACTION_TIMEOUT_MS);
    if(r.status===401){window.location.href='/login';return}
    if(!r.ok)throw new Error(await r.text());
    const data=await r.json();
    const f=document.forms.configForm;
    f.elements.wifiSsid.value=data.wifiSsid||'';
    wifiConfigLoaded=true;
    notice(data.hasWifiPassword?'WiFi SSID loaded. Password is stored but not shown.':'WiFi SSID loaded. No password is stored.','success');
  }catch(e){
    notice(e.name==='AbortError'?'Request timed out':e.message,'error');
  }
}

async function logout(){
  try{
    await requestWithTimeout('/api/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'},ACTION_TIMEOUT_MS);
  }catch(e){}
  window.location.href='/login';
}

function connectEvents(){
  if(!window.EventSource){
    fallbackPolling=true;
    setStream('Polling');
    loadStatus();
    return;
  }

  const eventUrl=`http://${location.hostname}:81/events`;

  try{
    eventSource=new EventSource(eventUrl);
  }catch(e){
    fallbackPolling=true;
    setStream('Polling');
    loadStatus();
    return;
  }

  eventSource.addEventListener('open',()=>{
    streamConnected=true;
    fallbackPolling=false;
    clearTimeout(statusTimer);
    setStream('Live','live');
    setUpdated('live');
  });

  eventSource.addEventListener('status',event=>{
    try{
      streamConnected=true;
      fallbackPolling=false;
      clearTimeout(statusTimer);
      setStream('Live','live');
      applyStatus(JSON.parse(event.data),'live');
    }catch(e){
      console.warn('Bad status event',e);
    }
  });

  eventSource.addEventListener('rfid',event=>{
    try{
      streamConnected=true;
      fallbackPolling=false;
      clearTimeout(statusTimer);
      setStream('Live','live');
      addRfidEvent(JSON.parse(event.data));
    }catch(e){
      console.warn('Bad RFID event',e);
    }
  });

  eventSource.onerror=()=>{
    streamConnected=false;
    setStream('Reconnecting');
    if(Date.now()-lastStatusAt>6000){
      fallbackPolling=true;
      scheduleStatus();
    }
  };

  setTimeout(()=>{
    if(!latest){
      fallbackPolling=true;
      setStream('Polling');
      loadStatus();
    }
  },1800);
}

document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
  activeTab=b.dataset.tab;
  document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
  b.classList.add('active');
  document.querySelectorAll('.tabPanel').forEach(p=>p.classList.add('hidden'));
  $(activeTab).classList.remove('hidden');
});

document.addEventListener('visibilitychange',scheduleStatus);

document.forms.configForm.onsubmit=async e=>{
  e.preventDefault();
  const fd=new FormData(e.target);
  const data=Object.fromEntries(fd.entries());
  ['canWriteCards','useTls','tlsInsecure','sendTappedAt'].forEach(k=>data[k]=fd.has(k));
  await apiPost('/api/config',data);
};

setInterval(()=>{
  if(latest)render(latest);
  if(latest&&!fallbackPolling&&Date.now()-lastStatusAt>10000){
    setStream('Reconnecting');
  }
},1000);

connectEvents();
</script>
</body>
</html>
)HTML";