#!/usr/bin/env python3
"""
generate_dashboard.py — builds dashboard.html from "Application Job Tracker.xlsx".

Re-runnable and importable:
  CLI:     python3 "generate_dashboard.py"      -> writes dashboard.html
  Server:  import generate_dashboard; generate_dashboard.build()  -> HTML string

Never modifies the xlsx. The generated page includes interactive controls
(status change, mark-applied) that work when the page is served by
serve_dashboard.py; opened as a plain file it degrades to read-only.

Requires: openpyxl  (python3 -m pip install --user openpyxl)
"""

import html
import sys
from datetime import date, datetime
from pathlib import Path

try:
    import openpyxl
except ImportError:
    sys.exit("openpyxl is required. Run:  python3 -m pip install --user openpyxl")

def _jobdash_data_dir(_default):
    """Pi deployment: use the Nextcloud data-dir copy of the Job Search folder
    when jobdash_pi.json (written by deploy_to_pi.py) sits next to this script."""
    try:
        import json as _json
        return Path(_json.loads(
            (_default / "jobdash_pi.json").read_text())["data_dir"])
    except Exception:
        return _default
HERE = _jobdash_data_dir(Path(__file__).resolve().parent)
XLSX = HERE / "Application Job Tracker.xlsx"
OUT = HERE / "dashboard.html"

STAGES = ["Applied", "Screen", "Interview", "Onsite", "Final", "Offer",
          "Rejected", "Ghosted", "Withdrawn"]
ODDS = ["High", "Medium", "Low"]

STAGE_CLASS = {
    "applied": "b-blue", "screen": "b-amber", "phone screen": "b-amber",
    "interview": "b-amber", "onsite": "b-amber", "final": "b-purple",
    "offer": "b-green", "rejected": "b-red", "ghosted": "b-gray",
    "withdrawn": "b-gray", "closed": "b-gray",
}
ODDS_CLASS = {"high": "b-green", "medium": "b-amber", "med": "b-amber", "low": "b-red"}


# ---------- helpers ----------

def esc(v):
    return html.escape(str(v).strip()) if v is not None else ""


def iso(v):
    if v is None:
        return ""
    if isinstance(v, (datetime, date)):
        return v.strftime("%Y-%m-%d")
    return str(v).strip()[:10]


def nice_date(s):
    try:
        return datetime.strptime(s, "%Y-%m-%d").strftime("%b %-d, %Y")
    except Exception:
        return s


def rows_of(ws):
    """Rows as dicts keyed by header, with '_row' = real worksheet row number."""
    headers = [str(c.value).strip() if c.value else "" for c in ws[1]]
    out = []
    for i, r in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
        if not r or r[0] in (None, ""):
            continue
        d = dict(zip(headers, r))
        d["_row"] = i
        out.append(d)
    return out


def badge_class(text, table, default="b-gray"):
    return table.get(str(text or "").strip().lower(), default)


def dream_dots(score):
    try:
        n = max(0, min(5, int(score)))
    except (TypeError, ValueError):
        return ""
    return ('<span class="dots" title="Dream score %d/5" aria-label="Dream score %d of 5">%s</span>'
            % (n, n, '<i class="on"></i>' * n + "<i></i>" * (5 - n)))


def status_select(kind, current, options, row, company, sheet):
    """Badge-styled native <select> (transparent select overlays badge text)."""
    cur = str(current or "").strip() or "—"
    opts = list(options)
    if cur not in opts and cur != "—":
        opts.insert(0, cur)
    table = STAGE_CLASS if kind == "stage" else ODDS_CLASS
    o = "".join(f'<option value="{esc(v)}"{" selected" if v == cur else ""}>{esc(v)}</option>'
                for v in opts)
    return (f'<span class="badge sel-wrap {badge_class(cur, table)}">{esc(cur)} ▾'
            f'<select class="sel" data-kind="{kind}" data-sheet="{sheet}" data-row="{row}" '
            f'data-company="{esc(company)}" data-cur="{esc(cur)}">{o}</select></span>')


# ---------- cards ----------

def lead_card(l, sheet="leads"):
    notes = esc(l.get("Notes"))
    verified = iso(l.get("Verified Live"))
    salary = esc(l.get("Salary"))
    link = esc(l.get("Link"))
    company = l.get("Company")
    parts = [
        '<article class="card">',
        '<div class="card-top"><div>',
        f'<h3>{esc(company)}</h3>',
        f'<p class="role">{esc(l.get("Role"))}</p>',
        "</div>",
        status_select("odds", l.get("Odds (honest)"), ODDS, l["_row"], company, sheet),
        "</div>",
        '<div class="meta">',
        f'<span class="meta-item">📍 {esc(l.get("Location"))}</span>' if l.get("Location") else "",
        f'<span class="meta-item salary">💰 {salary}</span>' if salary else "",
        (f'<span class="meta-item live">✓ live {nice_date(verified)}</span>' if verified else ""),
        "</div>",
        (f'<details class="notes"><summary>Why it fits</summary><p>{notes}</p></details>' if notes else ""),
        '<div class="actions">',
        (f'<button class="open apply" data-sheet="{sheet}" data-row="{l["_row"]}" '
         f'data-company="{esc(company)}">✓ Mark applied</button>'),
        (f'<a class="open" href="{link}" target="_blank" rel="noopener">Open ↗</a>' if link else ""),
        (f'<button class="open dead" data-sheet="{sheet}" data-row="{l["_row"]}" '
         f'data-company="{esc(company)}" '
         f'aria-label="No longer available" title="No longer available">✕</button>'),
        "</div>",
        "</article>",
    ]
    return "".join(p for p in parts if p)


def review_card(l):
    """Manual Review card: same look as a lead card, but instead of an odds
    selector / mark-applied it offers triage actions — route the row to
    Leads, Eng Leads, or Dead Leads (all via /api/review_move)."""
    notes = esc(l.get("Notes"))
    verified = iso(l.get("Verified Live"))
    salary = esc(l.get("Salary"))
    link = esc(l.get("Link"))
    company = l.get("Company")
    parts = [
        '<article class="card">',
        '<div class="card-top"><div>',
        f'<h3>{esc(company)}</h3>',
        f'<p class="role">{esc(l.get("Role"))}</p>',
        "</div>",
        '<span class="badge b-purple">Review</span>',
        "</div>",
        '<div class="meta">',
        f'<span class="meta-item">📍 {esc(l.get("Location"))}</span>' if l.get("Location") else "",
        f'<span class="meta-item salary">💰 {salary}</span>' if salary else "",
        (f'<span class="meta-item live">✓ live {nice_date(verified)}</span>' if verified else ""),
        "</div>",
        (f'<details class="notes"><summary>Notes</summary><p>{notes}</p></details>' if notes else ""),
        '<div class="actions">',
        (f'<button class="open mv" data-dest="leads" data-row="{l["_row"]}" '
         f'data-company="{esc(company)}">→ Leads</button>'),
        (f'<button class="open mv" data-dest="eng" data-row="{l["_row"]}" '
         f'data-company="{esc(company)}">→ Eng</button>'),
        (f'<a class="open" href="{link}" target="_blank" rel="noopener">Open ↗</a>' if link else ""),
        (f'<button class="open dead" data-sheet="review" data-row="{l["_row"]}" '
         f'data-company="{esc(company)}" '
         f'aria-label="Dead lead" title="Dead lead">✕</button>'),
        "</div>",
        "</article>",
    ]
    return "".join(p for p in parts if p)


def app_card(a):
    applied = iso(a.get("Date Applied"))
    link = esc(a.get("Link to JD"))
    notes = esc(a.get("Notes"))
    salary = esc(a.get("Salary"))
    company = a.get("Company")
    parts = [
        '<article class="card">',
        '<div class="card-top"><div>',
        f'<h3>{esc(company)}</h3>',
        f'<p class="role">{esc(a.get("Role"))}</p>',
        "</div>",
        status_select("stage", a.get("Stage"), STAGES, a["_row"], company, "apps"),
        "</div>",
        '<div class="meta">',
        f'<span class="meta-item">📍 {esc(a.get("Location"))}</span>' if a.get("Location") else "",
        f'<span class="meta-item salary">💰 {salary}</span>' if salary else "",
        f'<span class="meta-item">🏷 {esc(a.get("Industry"))}</span>' if a.get("Industry") else "",
        (f'<span class="meta-item" data-applied="{applied}">📅 {nice_date(applied)}'
         f' · <b class="days">…</b></span>' if applied else ""),
        dream_dots(a.get("Dream Score")),
        "</div>",
        (f'<details class="notes"><summary>Notes</summary><p>{notes}</p></details>' if notes else ""),
        '<div class="actions">',
        (f'<a class="open" href="{link}" target="_blank" rel="noopener">Job posting ↗</a>' if link else ""),
        (f'<button class="open arch" data-row="{a["_row"]}" data-company="{esc(company)}" '
         f'title="Move to Archived">🗄 Archive</button>'),
        "</div>",
        "</article>",
    ]
    return "".join(p for p in parts if p)


def archived_card(a):
    applied = iso(a.get("Date Applied"))
    link = esc(a.get("Link to JD"))
    notes = esc(a.get("Notes"))
    salary = esc(a.get("Salary"))
    company = a.get("Company")
    stage = esc(a.get("Stage"))
    archived_on = iso(a.get("Archived"))
    parts = [
        '<article class="card muted">',
        '<div class="card-top"><div>',
        f'<h3>{esc(company)}</h3>',
        f'<p class="role">{esc(a.get("Role"))}</p>',
        "</div>",
        f'<span class="badge b-gray">{stage or "—"}</span>',
        "</div>",
        '<div class="meta">',
        (f'<span class="meta-item">📅 applied {nice_date(applied)}</span>' if applied else ""),
        (f'<span class="meta-item">🗄 archived {nice_date(archived_on)}</span>' if archived_on else ""),
        f'<span class="meta-item">📍 {esc(a.get("Location"))}</span>' if a.get("Location") else "",
        f'<span class="meta-item salary">💰 {salary}</span>' if salary else "",
        "</div>",
        (f'<details class="notes"><summary>Notes</summary><p>{notes}</p></details>' if notes else ""),
        '<div class="actions">',
        (f'<button class="open unarch" data-row="{a["_row"]}" '
         f'data-company="{esc(company)}">↩ Unarchive</button>'),
        (f'<a class="open arch" href="{link}" target="_blank" rel="noopener">Posting ↗</a>' if link else ""),
        "</div>",
        "</article>",
    ]
    return "".join(p for p in parts if p)


# ---------- page ----------

TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Sohaib · Job Search</title>
<style>
:root{
  --bg:#f4f5f9; --card:#ffffff; --ink:#171923; --sub:#5b616e; --line:#e4e6ee;
  --accent:#4f6df5; --accent2:#8b5cf6; --chipbg:#eef0f8;
  --green-bg:#e3f6e9; --green-fg:#177a3d; --amber-bg:#fdf0d7; --amber-fg:#92600a;
  --red-bg:#fde4e4; --red-fg:#b3322e; --blue-bg:#e3ecfd; --blue-fg:#2755c4;
  --purple-bg:#eee6fd; --purple-fg:#6d3fc0; --gray-bg:#ebedf2; --gray-fg:#5b616e;
  --shadow:0 1px 2px rgba(20,24,40,.06),0 8px 24px -12px rgba(20,24,40,.14);
}
@media (prefers-color-scheme: dark){
  :root{
    --bg:#0e1017; --card:#181b26; --ink:#eef0f6; --sub:#9aa1b2; --line:#262a38;
    --accent:#7c93ff; --accent2:#a78bfa; --chipbg:#20242f;
    --green-bg:#12301d; --green-fg:#5bd48a; --amber-bg:#33270e; --amber-fg:#f0b64f;
    --red-bg:#3a1717; --red-fg:#ff8a86; --blue-bg:#16233d; --blue-fg:#8ab0ff;
    --purple-bg:#251b3d; --purple-fg:#c4a8ff; --gray-bg:#232633; --gray-fg:#a5abbb;
    --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.6);
  }
}
*{box-sizing:border-box;margin:0}
body{
  font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",Roboto,sans-serif;
  background:var(--bg);color:var(--ink);line-height:1.45;
  padding:max(14px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right))
          max(28px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));
  -webkit-text-size-adjust:100%;
}
.wrap{max-width:640px;margin:0 auto}
header{margin:6px 2px 16px}
h1{font-size:26px;font-weight:800;letter-spacing:-.02em;
   background:linear-gradient(90deg,var(--accent),var(--accent2));
   -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.updated{color:var(--sub);font-size:13px;margin-top:2px}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;
      padding:10px 6px;text-align:center;box-shadow:var(--shadow)}
.stat b{display:block;font-size:22px;font-weight:800;letter-spacing:-.02em}
.stat span{font-size:11px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em}
.tabs{display:flex;background:var(--chipbg);border-radius:12px;padding:3px;margin-bottom:14px}
.tabs button{flex:1;border:0;background:transparent;color:var(--sub);font:inherit;
  font-size:14px;font-weight:600;padding:9px 0;border-radius:10px;cursor:pointer;
  -webkit-tap-highlight-color:transparent}
.tabs button.on{background:var(--card);color:var(--ink);box-shadow:var(--shadow)}
section{display:none}section.on{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;
      padding:15px 15px 13px;margin-bottom:12px;box-shadow:var(--shadow)}
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
h3{font-size:17px;font-weight:700;letter-spacing:-.01em}
.role{color:var(--sub);font-size:14px;margin-top:1px}
.badge{flex-shrink:0;font-size:11.5px;font-weight:700;padding:4px 9px;border-radius:99px;
       white-space:nowrap;letter-spacing:.02em}
.sel-wrap{position:relative;overflow:hidden}
.sel{position:absolute;inset:0;width:100%;height:100%;opacity:0;font-size:16px;
     -webkit-appearance:none;appearance:none;border:0}
.b-green{background:var(--green-bg);color:var(--green-fg)}
.b-amber{background:var(--amber-bg);color:var(--amber-fg)}
.b-red{background:var(--red-bg);color:var(--red-fg)}
.b-blue{background:var(--blue-bg);color:var(--blue-fg)}
.b-purple{background:var(--purple-bg);color:var(--purple-fg)}
.b-gray{background:var(--gray-bg);color:var(--gray-fg)}
.meta{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 2px}
.meta-item{font-size:12.5px;background:var(--chipbg);color:var(--sub);
           padding:4px 9px;border-radius:8px}
.meta-item b{color:var(--ink)}
.salary{color:var(--green-fg);background:var(--green-bg);font-weight:600}
.live{color:var(--blue-fg);background:var(--blue-bg)}
.dots{display:inline-flex;align-items:center;gap:3px;padding:4px 9px;background:var(--chipbg);border-radius:8px}
.dots i{width:7px;height:7px;border-radius:50%;background:var(--line);display:inline-block}
.dots i.on{background:var(--accent)}
.notes{margin-top:10px;font-size:13.5px}
.notes summary{color:var(--accent);font-weight:600;cursor:pointer;list-style:none;
  -webkit-tap-highlight-color:transparent}
.notes summary::before{content:"▸ "}
.notes[open] summary::before{content:"▾ "}
.notes p{margin-top:6px;color:var(--sub);border-left:2px solid var(--line);padding-left:10px}
.actions{display:flex;gap:8px;margin-top:12px}
.open{flex:1;display:block;text-align:center;font-size:14.5px;font-weight:700;
  color:#fff;text-decoration:none;padding:11px;border-radius:12px;border:0;font-family:inherit;
  background:linear-gradient(90deg,var(--accent),var(--accent2));cursor:pointer;
  -webkit-tap-highlight-color:transparent}
.open.apply{background:linear-gradient(90deg,#1d9e55,#15b8a6)}
.open.dead{flex:0 0 46px;background:var(--red-bg);color:var(--red-fg);font-size:16px}
.open.arch{flex:0 0 auto;background:var(--gray-bg);color:var(--gray-fg)}
.open.armed{background:linear-gradient(90deg,#d97706,#dc2626);color:#fff}
.open.dead.armed{flex:0 0 auto;padding:11px 12px;font-size:13px}
.open.unarch{background:linear-gradient(90deg,var(--accent),var(--accent2))}
.card.muted{opacity:.85}
.archbox{margin-top:18px}
.archbox>summary{list-style:none;cursor:pointer;text-align:center;font-size:14px;
  font-weight:600;color:var(--sub);background:var(--chipbg);padding:12px;
  border-radius:12px;-webkit-tap-highlight-color:transparent}
.archbox>summary::before{content:"▸ "}
.archbox[open]>summary{margin-bottom:12px}
.archbox[open]>summary::before{content:"▾ "}
.open:active{opacity:.85}
.open[disabled]{opacity:.6}
.empty{color:var(--sub);text-align:center;padding:30px 0}
footer{color:var(--sub);font-size:12px;text-align:center;margin-top:22px}
.restart{display:block;margin:16px auto 0;background:none;border:1px solid var(--line);
  color:var(--sub);font:inherit;font-size:12.5px;font-weight:600;padding:8px 16px;
  border-radius:99px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.restart .hint{font-weight:400;opacity:.8}
.restart:active{opacity:.7}
.restart[disabled]{opacity:.5}
#prog{position:relative;margin:10px auto 0;max-width:320px;height:28px;
  background:var(--chipbg);border-radius:99px;overflow:hidden}
#progbar{position:absolute;left:0;top:0;bottom:0;width:4%;
  background:linear-gradient(90deg,var(--accent),var(--accent2));transition:width .35s}
#progbar.err{background:var(--red-fg)}
#proglabel{position:absolute;inset:0;display:flex;align-items:center;
  justify-content:center;font-size:12px;font-weight:700;z-index:1}
#toast{position:fixed;left:50%;bottom:max(24px,env(safe-area-inset-bottom));
  transform:translateX(-50%) translateY(20px);max-width:88%;background:var(--ink);
  color:var(--bg);font-size:13.5px;font-weight:600;padding:11px 16px;border-radius:12px;
  opacity:0;transition:.25s;pointer-events:none;text-align:center;z-index:9}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
<div class="wrap">
<header>
  <h1>Job Search</h1>
  <p class="updated">Updated __GENERATED__</p>
</header>

<div class="stats">
  <div class="stat"><b>__N_APPS__</b><span>Applied</span></div>
  <div class="stat"><b>__N_ACTIVE__</b><span>Active</span></div>
  <div class="stat"><b>__N_LEADS__</b><span>Leads</span></div>
  <div class="stat"><b>__N_HIGH__</b><span>High odds</span></div>
</div>

<div class="tabs" role="tablist">
  <button id="t-leads" class="on" onclick="show('leads')">Leads (__N_LEADS__)</button>
  <button id="t-eng" onclick="show('eng')">Engineering (__N_ENG__)</button>
  <button id="t-apps" onclick="show('apps')">Apps (__N_APPS__)</button>
  <button id="t-review" onclick="show('review')">Review (__N_REVIEW__)</button>
</div>

<section id="s-leads" class="on">
__LEADS__
</section>

<section id="s-eng">
__ENG__
</section>

<section id="s-review">
__REVIEW__
</section>

<section id="s-apps">
__APPS__
__ARCHBOX__
</section>

<footer>Generated __GENERATED__ from Application Job Tracker.xlsx<br>
Editable when served by serve_dashboard.py; read-only otherwise.</footer>
<button id="restartBtn" class="restart" hidden>↻ Restart server<span class="hint"> — applies updates</span></button>
<div id="prog" hidden><div id="progbar"></div><span id="proglabel">Restarting…</span></div>
</div>
<div id="toast"></div>
<script>
function show(which){
  for (const k of ['leads','eng','apps','review']){
    document.getElementById('s-'+k).classList.toggle('on', k===which);
    document.getElementById('t-'+k).classList.toggle('on', k===which);
  }
  try{history.replaceState(null,'','#'+which);}catch(e){}
}
var h=location.hash.slice(1);
if (h==='apps'||h==='eng'||h==='review') show(h);

var toastTimer;
function toast(msg){
  var t=document.getElementById('toast');
  t.textContent=msg; t.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer=setTimeout(function(){t.classList.remove('show');},3500);
}
async function api(path,payload,onFail){
  try{
    var r=await fetch(path,{method:'POST',
      headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
    var j={}; try{j=await r.json();}catch(e){}
    if(r.ok){ location.reload(); return; }
    toast(j.error||('Update failed ('+r.status+')'));
  }catch(e){
    toast('Read-only copy — updates need the dashboard server running on the Mac.');
  }
  if(onFail) onFail();
}
function initUI(){
  document.querySelectorAll('select.sel').forEach(function(s){
    s.addEventListener('change',function(){
      var v=s.value;
      api('/api/status',{
        sheet: s.dataset.sheet,
        row: +s.dataset.row, company: s.dataset.company, value: v
      }, function(){ s.value=s.dataset.cur; });
    });
  });
  // Two-tap confirm: window.confirm() is silently suppressed in iOS
  // home-screen (standalone) web apps, so dialogs can never gate actions.
  // First tap arms the button (3.5s), second tap fires.
  function armed(b, armText){
    if(b.dataset.armed){ delete b.dataset.armed; b.classList.remove('armed'); return true; }
    b.dataset.armed='1'; b.dataset.orig=b.textContent;
    b.textContent=armText; b.classList.add('armed');
    setTimeout(function(){
      if(b.dataset.armed){
        delete b.dataset.armed; b.textContent=b.dataset.orig; b.classList.remove('armed');
      }
    },3500);
    return false;
  }
  document.querySelectorAll('button.apply').forEach(function(b){
    b.addEventListener('click',function(){
      if(!armed(b,'Tap again to confirm ✓')) return;
      b.disabled=true; b.textContent='Moving…';
      api('/api/apply',{sheet:b.dataset.sheet, row:+b.dataset.row, company:b.dataset.company},
          function(){ b.disabled=false; b.textContent='✓ Mark applied'; });
    });
  });
  document.querySelectorAll('button.dead').forEach(function(b){
    b.addEventListener('click',function(){
      if(!armed(b,'Sure?')) return;
      b.disabled=true;
      api('/api/dead',{sheet:b.dataset.sheet, row:+b.dataset.row, company:b.dataset.company},
          function(){ b.disabled=false; b.textContent='✕'; });
    });
  });
  document.querySelectorAll('button.mv').forEach(function(b){
    b.addEventListener('click',function(){
      if(!armed(b,'Tap again ✓')) return;
      var orig=b.dataset.orig||b.textContent;
      b.disabled=true; b.textContent='Moving…';
      api('/api/review_move',{row:+b.dataset.row, company:b.dataset.company,
          dest:b.dataset.dest},
          function(){ b.disabled=false; b.textContent=orig; });
    });
  });
  document.querySelectorAll('button.arch').forEach(function(b){
    b.addEventListener('click',function(){
      b.disabled=true; b.textContent='Archiving…';
      api('/api/archive',{row:+b.dataset.row, company:b.dataset.company},
          function(){ b.disabled=false; b.textContent='🗄 Archive'; });
    });
  });
  document.querySelectorAll('button.unarch').forEach(function(b){
    b.addEventListener('click',function(){
      b.disabled=true; b.textContent='Restoring…';
      api('/api/unarchive',{row:+b.dataset.row, company:b.dataset.company},
          function(){ b.disabled=false; b.textContent='↩ Unarchive'; });
    });
  });
  // live "days since applied"
  document.querySelectorAll('[data-applied]').forEach(function(el){
    var d=new Date(el.getAttribute('data-applied')+'T00:00:00');
    if(isNaN(d)) return;
    var days=Math.max(0,Math.floor((Date.now()-d.getTime())/864e5));
    el.querySelector('.days').textContent=days+'d ago';
  });
  var rb=document.getElementById('restartBtn');
  if(rb && (location.protocol==='http:'||location.protocol==='https:')){
    rb.hidden=false;
    rb.onclick=restartServer;
  }
}

async function restartServer(){
  var rb=document.getElementById('restartBtn'), prog=document.getElementById('prog'),
      bar=document.getElementById('progbar'), lab=document.getElementById('proglabel');
  rb.disabled=true; prog.hidden=false;
  bar.classList.remove('err'); bar.style.width='4%'; lab.textContent='Restarting…';
  var oldBoot=null;
  try{
    var r0=await fetch('/api/version',{cache:'no-store'});
    if(r0.ok) oldBoot=(await r0.json()).boot||null;
  }catch(e){}
  try{ fetch('/api/restart',{method:'POST'}); }catch(e){}
  var start=Date.now(), pct=4;
  function done(ok,msg){
    clearInterval(anim); clearInterval(poll);
    bar.style.width='100%';
    if(!ok) bar.classList.add('err');
    lab.textContent=msg; rb.disabled=false;
    if(ok){
      toast('Server restarted — latest code is live');
      setTimeout(function(){location.reload();},900);
    }
  }
  var anim=setInterval(function(){
    pct=Math.min(90, pct+(90-pct)*0.09); bar.style.width=pct+'%';
  },300);
  var poll=setInterval(async function(){
    if(Date.now()-start>30000){ done(false,'Not back after 30s — check the Mac'); return; }
    try{
      var r=await fetch('/api/version',{cache:'no-store'});
      if(r.ok){
        var j=await r.json();
        if(!oldBoot || (j.boot && j.boot!==oldBoot)) done(true,'Back online');
      }
    }catch(e){}
  },500);
}
initUI();

// auto-refresh: poll the tracker version; re-render in place when it changes.
// Skipped automatically for the static read-only file (no http origin).
var VERSION="__VERSION__";
if (location.protocol==='http:'||location.protocol==='https:'){
  setInterval(async function(){
    try{
      var r=await fetch('/api/version',{cache:'no-store'});
      if(!r.ok) return;
      var j=await r.json();
      if(!j.v || j.v===VERSION) return;
      VERSION=j.v;
      var t=await (await fetch('/',{cache:'no-store'})).text();
      var doc=new DOMParser().parseFromString(t,'text/html');
      var fresh=doc.querySelector('.wrap');
      if(!fresh) return;
      document.querySelector('.wrap').innerHTML=fresh.innerHTML;  // keeps scroll
      initUI();
      var hh=location.hash.slice(1);
      show(hh==='apps'||hh==='eng'||hh==='review' ? hh : 'leads');
      toast('Tracker updated — showing latest');
    }catch(e){}
  },30000);
}
</script>
</body>
</html>
"""

ACTIVE_STAGES = {"applied", "screen", "phone screen", "interview", "onsite", "final", "offer"}


def build():
    """Read the xlsx and return the full dashboard HTML (fresh every call)."""
    wb = openpyxl.load_workbook(XLSX, data_only=True, read_only=True)
    apps = rows_of(wb["Job Tracker"])
    leads = rows_of(wb["Leads"]) if "Leads" in wb.sheetnames else []
    eng = rows_of(wb["Eng Leads"]) if "Eng Leads" in wb.sheetnames else []
    review = rows_of(wb["Manual Review"]) if "Manual Review" in wb.sheetnames else []
    archived = rows_of(wb["Archived"]) if "Archived" in wb.sheetnames else []
    wb.close()
    archived.sort(key=lambda r: iso(r.get("Archived")), reverse=True)

    apps.sort(key=lambda r: iso(r.get("Date Applied")), reverse=True)

    n_apps = len(apps)
    n_active = sum(1 for a in apps if str(a.get("Stage", "")).strip().lower() in ACTIVE_STAGES)
    n_leads = len(leads)
    n_eng = len(eng)
    n_high = sum(1 for l in leads + eng
                 if str(l.get("Odds (honest)", "")).strip().lower() == "high")

    leads_html = "\n".join(lead_card(l, "leads") for l in leads) or '<p class="empty">No open leads right now.</p>'
    eng_html = "\n".join(lead_card(l, "eng") for l in eng) or '<p class="empty">No engineering leads right now.</p>'
    review_html = ("\n".join(review_card(l) for l in review)
                   or '<p class="empty">Nothing waiting for review.</p>')
    apps_html = "\n".join(app_card(a) for a in apps) or '<p class="empty">No applications yet.</p>'
    archbox = ""
    if archived:
        archbox = ('<details class="archbox"><summary>Archived (%d)</summary>%s</details>'
                   % (len(archived), "\n".join(archived_card(a) for a in archived)))
    generated = datetime.now().strftime("%b %-d, %Y at %-I:%M %p")
    try:
        version = str(XLSX.stat().st_mtime)
    except OSError:
        version = "unknown"

    return (TEMPLATE
            .replace("__VERSION__", esc(version))
            .replace("__GENERATED__", esc(generated))
            .replace("__N_APPS__", str(n_apps))
            .replace("__N_ACTIVE__", str(n_active))
            .replace("__N_LEADS__", str(n_leads))
            .replace("__N_ENG__", str(n_eng))
            .replace("__N_REVIEW__", str(len(review)))
            .replace("__N_HIGH__", str(n_high))
            .replace("__LEADS__", leads_html)
            .replace("__ENG__", eng_html)
            .replace("__REVIEW__", review_html)
            .replace("__APPS__", apps_html)
            .replace("__ARCHBOX__", archbox))


def main():
    page = build()
    OUT.write_text(page, encoding="utf-8")
    print(f"Wrote {OUT}")


if __name__ == "__main__":
    main()
