<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jieyab89 SOCMINT X — Analytics</title>
<style>
  :root {
    --bg:         #0f1117;
    --surface:    #1a1d27;
    --surface2:   #21253a;
    --border:     #2a2d3a;
    --text:       #e8eaf0;
    --muted:      #8890a4;
    --accent:     #5865f2;
    --accent-bg:  #1e2240;
    --success:    #22c55e;
    --success-bg: #052011;
    --danger:     #ef4444;
    --danger-bg:  #1c0505;
    --font:       -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; min-height: 100vh; }

  /* ── Header (same shell as the other 3 pages) ── */
  header {
    padding: 14px 24px;
    border-bottom: 1px solid var(--border);
    background: var(--surface);
    display: flex;
    align-items: center;
    gap: 12px;
    position: sticky;
    top: 0;
    z-index: 100;
  }
  header h1 { font-size: 14px; font-weight: 600; }
  header .sep { color: var(--border); }
  header .sub { color: var(--muted); font-size: 12px; flex: 1; }
  .hamburger-btn {
    margin-left: auto;
    background: none;
    border: 1px solid var(--border);
    border-radius: 5px;
    color: var(--muted);
    cursor: pointer;
    padding: 5px 9px;
    font-size: 16px;
    line-height: 1;
    transition: all 0.15s;
  }
  .hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
  .nav-menu {
    position: absolute;
    top: 100%;
    right: 12px;
    margin-top: 6px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 7px;
    padding: 5px;
    min-width: 175px;
    box-shadow: 0 8px 28px rgba(0,0,0,0.5);
    z-index: 200;
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .nav-menu.hidden { display: none; }
  .nav-menu a {
    padding: 7px 12px;
    border-radius: 5px;
    color: var(--muted);
    text-decoration: none;
    font-size: 13px;
    transition: all 0.12s;
    display: block;
  }
  .nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
  .nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
  .nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }

  .back-to-top {
    position: fixed;
    bottom: 14px;
    right: 14px;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    background: var(--surface);
    border: 1px solid var(--border);
    color: #FFFFFF;
    font-size: 17px;
    cursor: pointer;
    z-index: 251;
    box-shadow: 0 4px 14px rgba(0,0,0,0.4);
    transition: all 0.15s;
    display: none;
    align-items: center;
    justify-content: center;
  }
  .back-to-top:hover { border-color: var(--accent); }
  .back-to-top.visible { display: flex; }

  /* ── Layout ── */
  .layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 49px); }
  @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }

  /* ── Sidebar (archive picker) ── */
  .sidebar { border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; }
  .sidebar-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
  .sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
  .sidebar-count { font-size: 11px; color: var(--muted); background: var(--bg); padding: 2px 7px; border-radius: 10px; }
  .sidebar-search {
    width: calc(100% - 32px); margin: 10px 16px 6px; padding: 6px 10px;
    background: var(--bg); border: 1px solid var(--border); color: var(--text);
    font-family: var(--font); font-size: 12px; border-radius: 6px; transition: border-color 0.15s;
  }
  .sidebar-search:focus { outline: none; border-color: var(--accent); }
  .sidebar-search::placeholder { color: var(--muted); }
  .archive-list { overflow-y: auto; flex: 1; padding: 8px; }
  .archive-entry { padding: 10px 12px; border-radius: 7px; border: 1px solid transparent; cursor: pointer; transition: all 0.12s; margin-bottom: 4px; }
  .archive-entry:hover { background: var(--surface2); border-color: var(--border); }
  .archive-entry.active { background: var(--accent-bg); border-color: var(--accent); }
  .ae-tool { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .ae-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
  .ae-date { font-size: 10.5px; color: var(--muted); opacity: 0.75; margin-top: 2px; }
  .ae-stats { font-size: 11px; color: var(--muted); margin-top: 3px; }
  .no-archives { padding: 40px 16px; text-align: center; color: var(--muted); font-size: 13px; }

  /* ── Main viewer ── */
  .viewer { overflow-y: auto; padding: 20px 22px 60px; }
  .empty-state { color: var(--muted); font-size: 13px; padding: 80px 20px; text-align: center; }
  .section { margin-bottom: 26px; }
  .section-title {
    font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--muted); margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
  }
  .section-note { font-size: 11px; color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
  .btn-toolbar {
    margin-left: auto;
    padding: 5px 12px;
    background: transparent;
    color: var(--muted);
    border: 1px solid var(--border);
    border-radius: 6px;
    font-family: var(--font);
    font-size: 11px;
    font-weight: 500;
    text-transform: none;
    letter-spacing: 0;
    cursor: pointer;
    transition: all 0.15s;
  }
  .btn-toolbar:hover { color: var(--text); border-color: var(--accent); }

  /* ── Sentiment breakdown ── */
  .sentiment-bar {
    display: flex; height: 34px; border-radius: 8px; overflow: hidden;
    border: 1px solid var(--border); margin-bottom: 12px;
  }
  .sentiment-seg { display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--bg); transition: filter 0.15s; }
  .sentiment-seg:hover { filter: brightness(1.12); }
  .sentiment-seg.seg-pro     { background: var(--success); }
  .sentiment-seg.seg-neutral { background: var(--muted); }
  .sentiment-seg.seg-con     { background: var(--danger); }
  .sentiment-seg.empty       { color: transparent; }
  .sentiment-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
  .stile {
    background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
    padding: 12px 14px; cursor: pointer; transition: all 0.12s;
  }
  .stile:hover { border-color: #3a3d50; }
  .stile.active { border-color: var(--accent); background: var(--accent-bg); }
  .stile-label { font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
  .stile-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
  .stile-dot.dot-pro     { background: var(--success); }
  .stile-dot.dot-neutral { background: var(--muted); }
  .stile-dot.dot-con     { background: var(--danger); }
  .stile-value { font-size: 22px; font-weight: 700; margin-top: 4px; }
  .stile-pct   { font-size: 11px; color: var(--muted); margin-top: 1px; }

  /* ── Cards grid (top users / top engagement) ── */
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }

  .user-list { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
  .user-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid rgba(42,45,58,0.6); }
  .user-row:last-child { border-bottom: none; }
  .user-rank { font-size: 11px; color: var(--muted); width: 18px; flex-shrink: 0; text-align: right; }
  .user-avatar {
    width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex-shrink: 0;
    border: 1px solid var(--border); background: var(--bg);
  }
  .user-avatar-fallback { display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: var(--muted); }
  .user-identity { flex: 1; min-width: 0; }
  .user-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .user-handle { font-size: 11px; color: var(--muted); }
  .user-count { font-size: 12px; color: var(--accent); font-weight: 600; flex-shrink: 0; }
  .verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }

  /* Same classes/colors as index.html/archive.html/graph.html's own
     account_age_flag badge — new/recent accounts are a classic bot/
     sockpuppet signal, worth being just as visible here as it already is
     everywhere else this data shows up. */
  .age-badge {
    display: inline-block; font-size: 10px; font-weight: 500; padding: 1px 7px;
    border-radius: 20px; border: 1px solid var(--border); margin-left: 6px; vertical-align: middle;
  }
  .age-badge.age-new         { color: var(--danger);  border-color: #7f1d1d; background: var(--danger-bg); }
  .age-badge.age-recent      { color: #f59e0b;         border-color: #78350f; background: #1c0e02; }
  .age-badge.age-established { color: var(--success); border-color: #14532d; background: var(--success-bg); }
  /* No colored bucket, since there genuinely isn't one here (id_forensics.py
     couldn't place this id in either range) — muted like sent-neutral,
     not styled to look like a real new/recent/established verdict. */
  .age-badge.age-unknown     { color: var(--muted);   border-color: var(--border); background: var(--bg); }

  .engagement-list { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
  .eng-row { padding: 10px 12px; border-bottom: 1px solid rgba(42,45,58,0.6); }
  .eng-row:last-child { border-bottom: none; }
  .eng-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
  .eng-handle { font-size: 12px; font-weight: 600; color: var(--text); }
  .eng-score { font-size: 11px; color: var(--accent); font-weight: 600; margin-left: auto; white-space: nowrap; }
  .eng-text { font-size: 12px; color: var(--text); opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }

  /* ── Word cloud ── */
  .wordcloud {
    background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
    padding: 18px 20px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px;
  }
  .wc-word { color: var(--text); font-weight: 600; line-height: 1; white-space: nowrap; cursor: default; transition: color 0.12s; }
  .wc-word:hover { color: var(--accent); }

  /* ── Volume calendar — GitHub-contributions-style heatmap of how many
     scored items landed on each day, so a topic's activity spikes/clusters
     over time are visible at a glance. Click a day to filter Items below. ── */
  .cal-outer { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
  .cal-scroll { overflow-x: auto; padding-bottom: 2px; }
  .cal-months { display: flex; gap: 3px; height: 14px; margin-bottom: 4px; width: max-content; }
  .cal-month-cell { width: 11px; flex: 0 0 auto; font-size: 10px; color: var(--muted); white-space: nowrap; overflow: visible; }
  .cal-grid { display: flex; gap: 3px; width: max-content; }
  .cal-week { display: flex; flex-direction: column; gap: 3px; }
  .cal-cell { width: 11px; height: 11px; border-radius: 2px; background: var(--border); opacity: 0.4; }
  .cal-cell[data-date] { cursor: pointer; }
  .cal-cell.cal-has-data { background: var(--accent); }
  .cal-cell.cal-level-1 { opacity: 0.28; }
  .cal-cell.cal-level-2 { opacity: 0.5; }
  .cal-cell.cal-level-3 { opacity: 0.75; }
  .cal-cell.cal-level-4 { opacity: 1; }
  .cal-cell.cal-active { outline: 2px solid var(--text); outline-offset: 1px; }
  .cal-legend { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); margin-top: 10px; flex-wrap: wrap; }
  .cal-legend .cal-cell { cursor: default; width: 10px; height: 10px; }
  .cal-filter-chip {
    display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text);
    background: var(--accent-bg); border: 1px solid var(--accent); border-radius: 20px;
    padding: 3px 10px; cursor: pointer; margin-left: 8px;
  }
  .cal-date-stat {
    margin-top: 8px;
    padding: 7px 12px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    font-size: 12px;
    color: var(--text);
  }

  /* ── Sentiment badge (shared by tiles/list) ── */
  .sent-badge {
    display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 8px;
    border-radius: 20px; border: 1px solid; text-transform: uppercase; letter-spacing: 0.03em; flex-shrink: 0;
  }
  .sent-badge.sent-pro     { color: var(--success); border-color: #14532d; background: var(--success-bg); }
  .sent-badge.sent-neutral { color: var(--muted);   border-color: var(--border); background: var(--bg); }
  .sent-badge.sent-con     { color: var(--danger);  border-color: #7f1d1d; background: var(--danger-bg); }
  .sent-badge.sent-mixed   { color: var(--accent);  border-color: #34348a; background: var(--accent-bg); }

  /* ── Account sentiment breakdown — who's pro/con/neutral, as accounts, not
     just individual posts ── */
  .account-sent-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
  .asg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
  .asg-count { font-size: 11px; color: var(--muted); }
  .user-row.clickable { cursor: pointer; }
  .user-row.clickable:hover { background: var(--surface2); }
  .user-breakdown { font-size: 10px; color: var(--muted); flex-shrink: 0; white-space: nowrap; }

  /* Auto-scroll — same idea as index.html's scroll-triggered load-more, just
     scoped to each small box instead of the whole page: a fixed-height
     panel that scrolls internally, revealing more rows as you scroll near
     its bottom instead of dumping everything (or a hard cutoff) at once. */
  .user-list.scrollable, .engagement-list.scrollable {
    max-height: 320px;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }
  .load-more-note {
    font-size: 11px;
    color: var(--muted);
    text-align: center;
    padding: 10px 0 2px;
  }

  /* ── Item browser ── */
  .item-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
  .item-search {
    padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text);
    font-family: var(--font); font-size: 12px; border-radius: 6px; width: 220px;
  }
  .item-search:focus { outline: none; border-color: var(--accent); }
  .item-count { font-size: 11px; color: var(--muted); }
  .item-list { display: flex; flex-direction: column; gap: 8px; }
  .item-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
  .item-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
  .item-author { font-size: 12px; font-weight: 600; color: var(--text); }
  .item-date { font-size: 11px; color: var(--muted); }
  .item-score { font-size: 11px; color: var(--muted); margin-left: auto; }
  .item-text { font-size: 13px; color: var(--text); word-break: break-word; white-space: pre-wrap; }
  .item-matches { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
  .match-chip {
    font-size: 10px; padding: 1px 7px; border-radius: 10px; border: 1px solid;
  }
  .match-chip.mc-pro     { color: var(--success); border-color: #14532d; background: var(--success-bg); }
  .match-chip.mc-con { color: var(--danger);  border-color: #7f1d1d; background: var(--danger-bg); }

  .spinner {
    display: inline-block; width: 13px; height: 13px; border: 2px solid var(--border);
    border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: -2px;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* Sentiment-analysis progress bar — same visual language as the archive
     media-download bar on index.html/graph.html/archive.html, so a
     long-running background job looks the same wherever one shows up. */
  .archive-bar-fill-wrap { height: 3px; background: var(--border); border-radius: 2px; margin-top: 8px; overflow: hidden; }
  .archive-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }

  .method-note {
    font-size: 11px; color: var(--muted); line-height: 1.6; background: var(--surface);
    border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 22px;
  }

  /* ── Social network graph ── */
  .sna-legend {
    display: flex;
    align-items: center;
    gap: 14px;
    font-size: 11px;
    color: var(--muted);
    margin-bottom: 10px;
    flex-wrap: wrap;
  }
  .sna-legend-item { display: flex; align-items: center; gap: 5px; }
  .sna-legend-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
  .sna-legend-dot.dot-pro     { background: var(--success); }
  .sna-legend-dot.dot-neutral { background: var(--muted); }
  .sna-legend-dot.dot-con     { background: var(--danger); }
  .sna-wrap {
    display: none;
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: hidden;
    background: var(--surface);
  }
  .sna-wrap.visible { display: block; }
  .sna-graph {
    width: 100%;
    height: 480px;
    background: var(--bg);
  }
  .sna-node-info {
    display: none;
    padding: 12px 16px;
    border-top: 1px solid var(--border);
    align-items: center;
    gap: 14px;
    flex-wrap: wrap;
  }
  .sna-node-info.visible { display: flex; }
  .sna-node-identity { min-width: 140px; }
  .sna-node-name { font-size: 13px; font-weight: 600; color: var(--text); }
  .sna-node-handle { font-size: 11px; color: var(--muted); }
  .sna-node-stats { display: flex; gap: 6px; flex-wrap: wrap; }
  .sna-node-total { font-size: 11px; color: var(--muted); margin-left: auto; }
</style>
</head>
<body>

<header>
  <h1>Jieyab89 SOCMINT X</h1>
  <span class="sep">/</span>
  <span class="sub">Analytics — sentiment clustering, top actors, word frequency</span>
  <button class="hamburger-btn" id="navToggle" aria-label="Menu">☰</button>
  <nav id="navMenu" class="nav-menu hidden">
    <a href="/">Home</a>
    <a href="/graph">Graph</a>
    <a href="/archives">Archives</a>
    <hr class="nav-divider">
    <a href="/analytics" class="current">Analytics</a>
    <a href="/cases">Cases</a>
  </nav>
</header>

<button id="backToTop" class="back-to-top" aria-label="Back to top" title="Back to top">↑</button>

<div class="layout">
  <aside class="sidebar">
    <div class="sidebar-head">
      <span class="sidebar-title">Archives</span>
      <span class="sidebar-count" id="archiveCount">0</span>
    </div>
    <input type="text" class="sidebar-search" id="archiveSearch" placeholder="Filter archives...">
    <div class="archive-list" id="archiveList">
      <div class="no-archives">Loading…</div>
    </div>
  </aside>

  <main class="viewer" id="viewer">
    <div class="empty-state">Pick an archive on the left to run sentiment clustering, top-actor, and word-frequency analysis on its raw data.</div>
  </main>
</div>

<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';

function esc(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]); }

// Same blue-checkmark badge index.html/archive.html (card_constants.js) and
// graph.html each render next to a name — this page doesn't load either of
// those, so it gets its own copy rather than a shared file just for one
// function. `u`/`item` can be either an accountSentiment() entry or a raw
// scored-item's own `.item`, both carry the same two field names.
function verifiedBadgeHtml(u) {
  if (!u || (!u.verified && !u.is_blue_verified)) return '';
  const title = u.is_blue_verified ? 'Blue verified (X Premium)' : 'Verified (legacy)';
  return `<span class="verified-badge" title="${esc(title)}">✓</span>`;
}

// Same account_age_flag badge index.html/archive.html/graph.html already
// render as a plain field row — a new/recent account is a classic bot/
// sockpuppet signal, worth surfacing here at a glance too, not just buried
// in a raw field somewhere this dashboard doesn't even show. `u`/`item`
// works the same as verifiedBadgeHtml() above: an accountSentiment() entry,
// sentiment.py's top_users() entry, or a raw scored-item's `.item` all
// carry the same field name.
const AGE_LABELS = { new: 'New account', recent: 'Recent account', established: 'Established account' };
function ageBadgeHtml(u) {
  if (!u) return '';
  if (u.account_age_flag) {
    const label = AGE_LABELS[u.account_age_flag] || u.account_age_flag;
    return `<span class="age-badge age-${esc(u.account_age_flag)}" title="${esc(u.account_age || '')}">${esc(label)}</span>`;
  }
  // account_age_flag is null in two different situations that look
  // identical unless account_age_precision is checked too: no account id
  // was ever available to enrich (nothing to show — most items, no badge
  // at all is correct), vs. id_forensics.py's enrich_account_age() DID
  // have an id but it fell into that module's own documented "unknown"
  // gap (an id just past the calibrated range — see id_forensics.py's
  // module docstring). The second case is still worth a visible marker:
  // index.html/archive.html/graph.html all still show this account's own
  // account_age text as a plain field row even here, and this dashboard
  // has no equivalent raw-field fallback view, so silently showing
  // nothing would make an intentionally-labeled "we don't know" look
  // exactly like "there was never anything to know" instead.
  if (u.account_age_precision === 'unknown') {
    return `<span class="age-badge age-unknown" title="${esc(u.account_age || 'Account age could not be determined')}">Age unknown</span>`;
  }
  return '';
}

// Same allowlist index.html/graph.html apply to avatar/banner URLs — https
// only, no quote/angle-bracket/whitespace/paren characters. Not strictly
// required for a plain <img src> (no second CSS-parsing pass involved the
// way a background-image url() has), but kept consistent with the other
// two pages rather than being the one spot that skips it.
function isSafeImageUrl(u) {
  return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
}

// Hamburger nav
(function () {
  const btn  = document.getElementById('navToggle');
  const menu = document.getElementById('navMenu');
  btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('hidden'); });
  document.addEventListener('click', () => menu.classList.add('hidden'));
  menu.addEventListener('click', e => e.stopPropagation());
})();

// Back to top — the page itself is what scrolls (the sidebar/viewer split
// is a grid column layout, not a fixed-height scroll pane).
(function () {
  const btn = document.getElementById('backToTop');
  window.addEventListener('scroll', () => {
    btn.classList.toggle('visible', window.scrollY > 400);
  });
  btn.addEventListener('click', () => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  });
})();

// Click an account row (top-actors list or the pro/con/neutral/mixed
// breakdown) to filter the Items browser down to just that account — bound
// once here on the persistent #viewer container (delegated), rather than
// re-bound inside renderDashboard() every time an archive is opened, which
// would stack a duplicate listener on every switch.
document.getElementById('viewer').addEventListener('click', (e) => {
  const row = e.target.closest('.user-row.clickable');
  if (!row || !activeData) return;
  const handle = row.dataset.handle;
  const search = document.getElementById('itemSearch');
  if (!search) return;
  search.value = handle;
  activeFilter = null;
  document.querySelectorAll('.stile').forEach(t => t.classList.remove('active'));
  renderItemList();
  document.getElementById('itemList').scrollIntoView({ behavior: 'smooth', block: 'start' });
});

// Calendar day click — filters Items to that day; clicking the same day (or
// the "Filtered: …" chip) clears it. Delegated on #viewer like the account
// click handler above, since #calendarSection is rebuilt on every archive
// switch and every toggle.
document.getElementById('viewer').addEventListener('click', (e) => {
  if (!activeData) return;
  const clear = e.target.closest('#calClearFilter');
  const cell  = e.target.closest('.cal-cell[data-date]');
  if (!clear && !cell) return;

  if (clear || activeDateFilter === cell.dataset.date) {
    activeDateFilter = null;
  } else if (cell.classList.contains('cal-has-data')) {
    activeDateFilter = cell.dataset.date;
  } else {
    return;   // clicked an empty (no-data) day — nothing to filter to
  }

  const calSection = document.getElementById('calendarSection');
  if (calSection) calSection.innerHTML = calendarHtml(dailyVolume(activeData));
  renderItemList();
});

const archiveList   = document.getElementById('archiveList');
const archiveCount  = document.getElementById('archiveCount');
const archiveSearch = document.getElementById('archiveSearch');
const viewer        = document.getElementById('viewer');

let allArchives = [];
let activeId    = null;
let activeData  = null;      // last /api/analytics/<id> payload
let activeFilter = null;     // 'pro' | 'neutral' | 'con' | null (item browser filter)
let activeDateFilter = null; // 'YYYY-MM-DD' | null (calendar-driven item browser filter)

// ── Resilient fetch — auto-retry transient failures ─────────────────────────
// res.json() throws a raw SyntaxError ("unexpected character at line 1
// column 1...") whenever a response body isn't valid JSON — a dropped/
// truncated connection can do this even when the server itself is fine.
// Retries a few times with a short backoff (self-heals a one-off network
// blip automatically) before surfacing a real, readable error. A
// well-formed {ok:false,...} response is NOT retried — that's the server
// correctly answering "not found" / "corrupted," retrying wouldn't change it.
async function fetchJsonRetry(url, opts, attempts = 3) {
  let lastErr;
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch(url, opts);
      return await res.json();
    } catch (e) {
      lastErr = e;
      if (i < attempts - 1) await new Promise(r => setTimeout(r, 500 * (i + 1)));
    }
  }
  throw lastErr;
}

// ── Sidebar ──────────────────────────────────────────────────────────────────

async function loadSidebar() {
  let json;
  try {
    json = await fetchJsonRetry('/api/archive/list');
  } catch (e) {
    archiveList.innerHTML = `<div class="no-archives" style="color:var(--danger)">Couldn't load archives — ${esc(e.message || String(e))}<br><button type="button" class="btn-toolbar" id="retrySidebarBtn" style="margin-top:8px">Retry</button></div>`;
    document.getElementById('retrySidebarBtn').addEventListener('click', loadSidebar);
    return;
  }

  if (!json.ok || !json.archives.length) {
    allArchives = [];
    archiveList.innerHTML = json.ok
      ? '<div class="no-archives">No archives yet.<br>Save one from the search page, graph, or geo search first.</div>'
      : `<div class="no-archives" style="color:var(--danger)">${esc(json.error)}</div>`;
    archiveCount.textContent = '0';
    return;
  }

  allArchives = json.archives;
  renderArchiveList(archiveSearch.value);
}

function queryLabel(q) {
  return (q && (q.searchQuery || q.targetUsername || q.targetTweetId || q.targetCommunityId || q.query)) || '';
}

function archiveMatchText(a) {
  return [a.tool || '', queryLabel(a.query || {}), a.id || ''].join(' ').toLowerCase();
}

function renderArchiveList(filterText = '') {
  const q        = filterText.toLowerCase().trim();
  const filtered = q ? allArchives.filter(a => archiveMatchText(a).includes(q)) : allArchives;

  archiveCount.textContent = q ? `${filtered.length} / ${allArchives.length}` : String(allArchives.length);

  if (!filtered.length) {
    archiveList.innerHTML = '<div class="no-archives">No archives match your search.</div>';
    return;
  }

  archiveList.innerHTML = filtered.map(entryHtml).join('');
  archiveList.querySelectorAll('.archive-entry').forEach(el => {
    el.classList.toggle('active', el.dataset.id === activeId);
    el.addEventListener('click', () => loadAnalytics(el.dataset.id));
  });
}

archiveSearch.addEventListener('input', () => renderArchiveList(archiveSearch.value));

function entryHtml(a) {
  const tool  = (a.tool || 'unknown').replace(/_/g, ' ');
  const date  = a.archived_at ? a.archived_at.replace('T', ' ') : '—';
  const query = queryLabel(a.query || {});
  return `
    <div class="archive-entry" data-id="${esc(a.id)}">
      <div class="ae-tool">${esc(tool)}</div>
      <div class="ae-date">${esc(date)}</div>
      ${query ? `<div class="ae-meta">${esc(query)}</div>` : ''}
      <div class="ae-stats">${a.total_items || 0} items</div>
    </div>`;
}

// ── Analysis ──────────────────────────────────────────────────────────────────
// ML sentiment scoring runs locally on CPU at ~11-12ms/item (measured) — a
// large archive (thousands of items) can genuinely take a minute-plus, so
// this starts the job on the server (background thread, see app.py's
// analytics_start/_run_analytics) and polls for progress instead of
// blocking on one long request with nothing to show but a static spinner.

async function loadAnalytics(id) {
  activeId      = id;
  activeFilter  = null;
  activeDateFilter = null;
  archiveList.querySelectorAll('.archive-entry').forEach(el => el.classList.toggle('active', el.dataset.id === id));

  viewer.innerHTML = `
    <div class="empty-state">
      <div style="max-width:340px;margin:0 auto;text-align:left">
        <div><span class="spinner"></span> <span id="analyzeStatusText">Starting analysis…</span></div>
        <div class="archive-bar-fill-wrap"><div class="archive-bar-fill" id="analyzeBarFill" style="width:0%"></div></div>
      </div>
    </div>`;

  try {
    const startJson = await fetchJsonRetry('/api/analytics/' + encodeURIComponent(id) + '/start', { method: 'POST' });
    if (activeId !== id) return;   // user already clicked a different archive
    if (!startJson.ok) { showAnalyticsError(id, startJson.error || 'Analysis failed to start'); return; }
    pollAnalytics(id);
  } catch (e) {
    showAnalyticsError(id, e.message || String(e));
  }
}

function showAnalyticsError(id, message) {
  if (activeId !== id) return;   // a later archive click already replaced this view
  viewer.innerHTML = `<div class="empty-state">Couldn't analyze this archive — ${esc(message)}<br><button type="button" class="btn-toolbar" id="retryAnalyticsBtn" style="margin-top:8px">Retry</button></div>`;
  document.getElementById('retryAnalyticsBtn').addEventListener('click', () => loadAnalytics(id));
}

function pollAnalytics(id) {
  const interval = setInterval(async () => {
    // Stop polling for an archive the user has since clicked away from —
    // otherwise a slow poll response landing late could clobber whatever
    // (possibly already-finished) view is on screen now.
    if (activeId !== id) { clearInterval(interval); return; }

    let json;
    try {
      json = await fetchJsonRetry('/api/analytics/' + encodeURIComponent(id) + '/status');
    } catch (e) {
      clearInterval(interval);
      showAnalyticsError(id, e.message || String(e));
      return;
    }
    if (activeId !== id) { clearInterval(interval); return; }

    if (!json.ok) {
      clearInterval(interval);
      showAnalyticsError(id, json.error || 'Analysis failed');
      return;
    }
    if (json.status === 'done') {
      clearInterval(interval);
      activeData = json;
      renderDashboard();
      return;
    }
    // status === 'scoring' — update the bar and keep polling
    const total = json.total || 0;
    const pct   = total > 0 ? Math.round((json.progress / total) * 100) : 0;
    const statusText = document.getElementById('analyzeStatusText');
    const fill        = document.getElementById('analyzeBarFill');
    if (statusText) statusText.textContent = total > 0 ? `Scoring ${json.progress} / ${total} items…` : 'Preparing…';
    if (fill) fill.style.width = pct + '%';
  }, 700);
}

const SENT_LABELS = { pro: 'Pro', neutral: 'Neutral', con: 'Con', mixed: 'Mixed' };
let snaCy = null;   // lazily built the first time "View as Graph" is clicked

function renderDashboard() {
  // A previous archive's graph instance (if any) is about to lose its
  // container to the innerHTML rewrite below — destroy it cleanly first
  // rather than leaving a detached Cytoscape instance running.
  if (snaCy) { snaCy.destroy(); snaCy = null; }

  const d = activeData;
  if (!d.total_scored) {
    viewer.innerHTML = '<div class="empty-state">This archive has no text content to analyze (e.g. a pure follower/following list, or Wayback/CSE-only records with nothing scraped).</div>';
    return;
  }

  viewer.innerHTML = `
    <div class="method-note">
      ${d.method === 'ml'
        ? 'Sentiment is scored by a multilingual model (XLM-RoBERTa, fine-tuned on tweets across 8 languages and reasonably capable well beyond those) — works on non-Indonesian text, not just the lexicon fallback below. Still a heuristic, not ground truth: short text, sarcasm and irony all still degrade accuracy. Click a tile below to see which items landed in it and the model\'s confidence per item.'
        : 'Sentiment is scored con an Indonesian positive/negative word lexicon with negation handling (e.g. "tidak bagus" flips to con) — a transparent rule-based heuristic, Indonesian-only. Install torch+transformers (see requirements.txt) for multilingual ML-based scoring instead. Click a tile below to see exactly which items landed in it and which words drove each score.'}
      ${d.total_items - d.total_scored > 0 ? `${d.total_items - d.total_scored} of ${d.total_items} record(s) had no text to score (bare follower/following/user entries) and are excluded below.` : ''}
    </div>

    <div class="section">
      <div class="section-title">Sentiment clustering <span class="section-note">${d.total_scored} scored</span></div>
      ${sentimentBarHtml(d)}
      <div class="sentiment-tiles">
        ${sentimentTileHtml('pro', d)}
        ${sentimentTileHtml('neutral', d)}
        ${sentimentTileHtml('con', d)}
      </div>
    </div>

    <div class="section">
      <div class="section-title">Volume over time <span class="section-note">items per day — click a day to filter Items below, click again to clear</span></div>
      <div id="calendarSection">${calendarHtml(dailyVolume(d))}</div>
    </div>

    <div class="section">
      <div class="section-title">Account sentiment breakdown <span class="section-note">accounts grouped by their own pro/con/neutral lean, not just individual posts — click an account to filter Items below</span></div>
      ${accountSentimentHtml(d)}
    </div>

    <div class="grid-2 section">
      <div>
        <div class="item-toolbar" style="margin-bottom:8px">
          <div class="section-title" style="margin-bottom:0">Most active accounts <span class="item-count" id="topUsersCount">${(d.top_users || []).length}</span></div>
          <input type="text" class="item-search" id="topUsersSearch" placeholder="Search accounts...">
        </div>
        ${topUsersHtml(d.top_users, accountSentiment(d))}
      </div>
      <div>
        <div class="item-toolbar" style="margin-bottom:8px">
          <div class="section-title" style="margin-bottom:0">Most engagement <span class="item-count" id="topEngagementCount">${(d.top_engagement || []).length}</span></div>
          <input type="text" class="item-search" id="engSearch" placeholder="Search text or author...">
        </div>
        ${topEngagementHtml(d.top_engagement)}
      </div>
    </div>

    <div class="section">
      <div class="section-title">
        Social network graph <span class="section-note">accounts, sized by activity, colored by sentiment mix</span>
        <button type="button" id="btnSnaGraph" class="btn-toolbar">View as Graph</button>
      </div>
      <div class="sna-legend">
        <span class="sna-legend-item"><span class="sna-legend-dot dot-pro"></span>Pro</span>
        <span class="sna-legend-item"><span class="sna-legend-dot dot-neutral"></span>Neutral</span>
        <span class="sna-legend-item"><span class="sna-legend-dot dot-con"></span>Con</span>
        <span class="sna-legend-item">Node size = post volume · edges = reply/quote relationships found within this archive · click a node for details</span>
      </div>
      <div class="sna-wrap" id="snaWrap">
        <div class="sna-graph" id="snaGraph"></div>
        <div class="sna-node-info" id="snaNodeInfo"></div>
      </div>
    </div>

    <div class="section">
      <div class="section-title">Word frequency</div>
      ${wordCloudHtml(d.word_freq)}
    </div>

    <div class="section">
      <div class="item-toolbar">
        <div class="section-title" style="margin-bottom:0">Items <span class="item-count" id="itemCount"></span></div>
        <input type="text" class="item-search" id="itemSearch" placeholder="Search text or author...">
      </div>
      <div class="item-list" id="itemList"></div>
      <div id="itemListSentinel" style="height:1px"></div>
      <div class="load-more-note" id="itemListStatus" style="display:none"></div>
    </div>
  `;

  document.querySelectorAll('.stile').forEach(el => {
    el.addEventListener('click', () => {
      const label = el.dataset.label;
      activeFilter = activeFilter === label ? null : label;
      document.querySelectorAll('.stile').forEach(t => t.classList.toggle('active', t.dataset.label === activeFilter));
      renderItemList();
    });
  });
  // Debounced — on a large archive (thousands of scored items) re-filtering
  // and re-rendering the whole list on every single keystroke makes typing
  // itself feel laggy; 180ms is short enough to still feel instant once
  // typing pauses, long enough to collapse a fast typist's keystrokes into
  // one render instead of one per character.
  let itemSearchDebounce = null;
  document.getElementById('itemSearch').addEventListener('input', () => {
    clearTimeout(itemSearchDebounce);
    itemSearchDebounce = setTimeout(renderItemList, 180);
  });
  renderItemList();

  // Same debounced-search pattern as itemSearch above, scoped to the Most
  // active accounts / Most engagement boxes — their search inputs live in
  // the section header now (see renderDashboard()'s own template above),
  // so they're always present regardless of whether either box has data.
  let topUsersSearchDebounce = null;
  document.getElementById('topUsersSearch').addEventListener('input', (e) => {
    const val = e.target.value;
    clearTimeout(topUsersSearchDebounce);
    topUsersSearchDebounce = setTimeout(() => {
      topUsersQuery = val.toLowerCase().trim();
      renderTopUsersBox();
    }, 180);
  });

  let engSearchDebounce = null;
  document.getElementById('engSearch').addEventListener('input', (e) => {
    const val = e.target.value;
    clearTimeout(engSearchDebounce);
    engSearchDebounce = setTimeout(() => {
      topEngagementQuery = val.toLowerCase().trim();
      renderTopEngagementBox();
    }, 180);
  });

  // Re-bind both scroll-observers to this render's freshly built sentinel
  // nodes (see the comments above itemListObserver / rebindAccountScrollObservers
  // for why re-binding is needed on every archive switch).
  itemListObserver.disconnect();
  const itemSentinel = document.getElementById('itemListSentinel');
  if (itemSentinel) itemListObserver.observe(itemSentinel);
  rebindAccountScrollObservers();
  rebindTopListObservers();

  document.getElementById('btnSnaGraph').addEventListener('click', toggleSnaGraph);
}

// ── Social network graph ─────────────────────────────────────────────────────
// Accounts as nodes — each one's fill is a native Cytoscape pie split by its
// own pro/neutral/con proportions (a Maltego-style at-a-glance indicator:
// mostly-green vs mostly-red vs mixed is readable across the whole graph at
// once, not just one account at a time), sized by how many items in this
// archive that account produced. Edges are reply relationships derived
// entirely from data already loaded in scored_items — no extra request:
// when an item's in_reply_to_tweet_id matches another item's own id within
// this same archive, that's a real reply link between the two authors.
function toggleSnaGraph() {
  const wrap = document.getElementById('snaWrap');
  const btn  = document.getElementById('btnSnaGraph');
  const showing = wrap.classList.toggle('visible');
  btn.textContent = showing ? 'Hide Graph' : 'View as Graph';
  if (showing && !snaCy) buildSnaGraph();
}

function buildSnaGraph() {
  const d = activeData;
  const accounts = accountSentiment(d);   // handle -> { pro, neutral, con, total, name, avatar }
  const idToAuthor = {}; // raw item id -> handle, for resolving reply edges

  d.scored_items.forEach(it => {
    const raw    = it.item || {};
    const handle = it.author || raw.screen_name || raw.user;
    if (handle && raw.id != null) idToAuthor[String(raw.id)] = handle;
  });

  const handles = Object.keys(accounts);
  const graphEl = document.getElementById('snaGraph');
  if (!handles.length) {
    graphEl.innerHTML = '<div class="empty-state" style="padding:24px">No identifiable accounts to graph.</div>';
    return;
  }

  const seenEdge = new Set();
  const edges = [];
  d.scored_items.forEach(it => {
    const raw       = it.item || {};
    const replyTo   = raw.in_reply_to_tweet_id;
    const fromHandle = it.author || raw.screen_name || raw.user;
    if (!replyTo || !fromHandle) return;
    const toHandle = idToAuthor[String(replyTo)];
    if (!toHandle || toHandle === fromHandle) return;
    const key = fromHandle + '→' + toHandle;
    if (seenEdge.has(key)) return;
    seenEdge.add(key);
    edges.push({ data: { id: 'e_' + edges.length, source: fromHandle, target: toHandle, label: 'replied to' } });
  });

  // Quote-tweet edges — quoted_user is already denormalized straight onto
  // the record (cookie_client.py's _tweet_to_dict), so unlike the reply
  // edge above this needs no idToAuthor lookup — just confirm the quoted
  // account is itself a node here (i.e. it authored something else in this
  // same archive; if it never did, there's no node for the edge to point
  // to and it's silently skipped, same rule idToAuthor enforces for replies).
  d.scored_items.forEach(it => {
    const raw        = it.item || {};
    const quotedUser = raw.quoted_user;
    const fromHandle  = it.author || raw.screen_name || raw.user;
    if (!quotedUser || !fromHandle || quotedUser === fromHandle || !accounts[quotedUser]) return;
    const key = fromHandle + '→' + quotedUser;
    if (seenEdge.has(key)) return;
    seenEdge.add(key);
    edges.push({ data: { id: 'e_' + edges.length, source: fromHandle, target: quotedUser, label: 'quoted' } });
  });

  const counts   = handles.map(h => accounts[h].total);
  const maxCount = Math.max(...counts);
  const minCount = Math.min(...counts);
  const sameSize = maxCount === minCount;

  const nodes = handles.map(h => {
    const a   = accounts[h];
    const pct = n => a.total ? Math.round((n / a.total) * 100) : 0;
    return {
      data: {
        id: h,
        label: '@' + h + ' (' + a.total + ')',
        name: a.name, avatar: a.avatar,
        verified: a.verified, is_blue_verified: a.is_blue_verified,
        account_age_flag: a.account_age_flag, account_age: a.account_age,
        account_age_precision: a.account_age_precision,
        pro: a.pro, neutral: a.neutral, con: a.con, total: a.total,
        proPct: pct(a.pro), neutralPct: pct(a.neutral), conPct: pct(a.con),
      },
    };
  });

  const sizeStyle = sameSize
    ? { width: 42, height: 42 }
    : { width: `mapData(total, ${minCount}, ${maxCount}, 26, 74)`, height: `mapData(total, ${minCount}, ${maxCount}, 26, 74)` };

  snaCy = cytoscape({
    container: graphEl,
    elements: { nodes, edges },
    minZoom: 0.15,
    maxZoom: 3,
    style: [
      { selector: 'node', style: Object.assign({
          shape: 'ellipse',
          label: 'data(label)',
          color: '#e8eaf0',
          'font-size': '9px',
          'min-zoomed-font-size': 6,
          'text-valign': 'bottom',
          'text-margin-y': '6px',
          'text-wrap': 'ellipsis',
          'text-max-width': '90px',
          'border-width': 2,
          'border-color': '#2a2d3a',
          'pie-size': '100%',
          'pie-1-background-color': '#22c55e',
          'pie-1-background-size': 'data(proPct)',
          'pie-2-background-color': '#8890a4',
          'pie-2-background-size': 'data(neutralPct)',
          'pie-3-background-color': '#ef4444',
          'pie-3-background-size': 'data(conPct)',
      }, sizeStyle) },
      { selector: 'edge', style: {
          'line-color': '#FFFFFF',
          'line-opacity': 0.45,
          width: 1,
          'target-arrow-color': '#FFFFFF',
          'target-arrow-shape': 'triangle',
          'arrow-scale': 0.8,
          'curve-style': 'bezier',
          label: 'data(label)',
          'font-size': '7px',
          color: '#8890a4',
          'text-background-color': '#0f1117',
          'text-background-opacity': 0.85,
          'text-background-padding': '2px',
      }},
      { selector: 'node:selected', style: {
          'border-width': 3,
          'border-color': '#5865f2',
      }},
    ],
    layout: {
      name: 'cose', animate: true, animationDuration: 450,
      nodeRepulsion: 9000, idealEdgeLength: 90, gravity: 0.4, numIter: 1000, fit: true, padding: 30,
    },
  });

  snaCy.on('tap', 'node', evt => showSnaNodeInfo(evt.target.data()));
}

function showSnaNodeInfo(nd) {
  const panel = document.getElementById('snaNodeInfo');
  panel.classList.add('visible');
  panel.innerHTML = `
    <div class="sna-node-identity">
      <div class="sna-node-name">${esc(nd.name || nd.id)}${verifiedBadgeHtml(nd)}</div>
      <div class="sna-node-handle">@${esc(nd.id)}${ageBadgeHtml(nd)}</div>
    </div>
    <div class="sna-node-stats">
      <span class="sent-badge sent-pro">Pro ${nd.pro}</span>
      <span class="sent-badge sent-neutral">Neutral ${nd.neutral}</span>
      <span class="sent-badge sent-con">Con ${nd.con}</span>
    </div>
    <div class="sna-node-total">${nd.total} item(s) total</div>
  `;
}

function sentimentBarHtml(d) {
  const total = d.total_scored || 1;
  const segs = ['pro', 'neutral', 'con'].map(label => {
    const pct = d.sentiment_pct[label] || 0;
    if (!pct) return '';
    return `<div class="sentiment-seg seg-${label}" style="flex:${d.sentiment_counts[label]}" title="${SENT_LABELS[label]}: ${pct}%">${pct >= 8 ? pct + '%' : ''}</div>`;
  }).join('');
  return `<div class="sentiment-bar">${segs}</div>`;
}

function sentimentTileHtml(label, d) {
  return `
    <div class="stile" data-label="${label}">
      <div class="stile-label"><span class="stile-dot dot-${label}"></span>${SENT_LABELS[label]}</div>
      <div class="stile-value">${d.sentiment_counts[label] || 0}</div>
      <div class="stile-pct">${d.sentiment_pct[label] || 0}% of scored items</div>
    </div>`;
}

// ── Item dates ────────────────────────────────────────────────────────────
// created_at is set by cookie_client/xquik_client/wayback_client/google_cse_
// client on the raw item (Twitter's classic date string, e.g. "Wed Oct 10
// 20:19:24 +0000 2018" — a format the Date constructor parses natively).
// retweeted_at/published_at/date cover the odd record shape that doesn't use
// created_at. Returns null (not a bare user record, no text, or an
// unparseable date) rather than throwing — dates are best-effort metadata,
// not something scoring depends on.
function itemDate(it) {
  const raw = (it && it.item) || {};
  const s = raw.created_at || raw.retweeted_at || raw.published_at || raw.date;
  if (!s) return null;
  const dt = new Date(s);
  return isNaN(dt.getTime()) ? null : dt;
}

function dateKey(dt) {
  // UTC calendar day, not local — keeps the calendar's bucketing stable
  // regardless of which timezone the browser viewing it happens to be in.
  return dt.toISOString().slice(0, 10);
}

function formatItemDate(dt) {
  return dt.toLocaleString('en-US', {
    day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC',
  });
}

// Memoized the same way accountSentiment() is — buildDashboard only needs
// this computed once per archive, not once per re-render.
let _dailyCache = null;
let _dailyCacheFor = null;
function dailyVolume(d) {
  if (_dailyCacheFor === d) return _dailyCache;
  const days = new Map();   // 'YYYY-MM-DD' -> { total, pro, neutral, con }
  let undated = 0;
  d.scored_items.forEach(it => {
    const dt = itemDate(it);
    if (!dt) { undated++; return; }
    const key = dateKey(dt);
    if (!days.has(key)) days.set(key, { total: 0, pro: 0, neutral: 0, con: 0 });
    const bucket = days.get(key);
    bucket.total++;
    bucket[it.label]++;
  });
  const result = { days, undated };
  _dailyCache = result;
  _dailyCacheFor = d;
  return result;
}

function monthLabelsHtml(weeks) {
  let lastMonth = null;
  return weeks.map(week => {
    const month = week[0].key.slice(0, 7);
    if (month === lastMonth) return '<div class="cal-month-cell"></div>';
    lastMonth = month;
    const label = new Date(week[0].key + 'T00:00:00Z').toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
    return `<div class="cal-month-cell">${label}</div>`;
  }).join('');
}

function calendarHtml(daily) {
  const { days, undated } = daily;
  if (!days.size) return '<div class="empty-state" style="padding:24px">No dated items to plot (raw records had no usable timestamp).</div>';

  const keys = Array.from(days.keys()).sort();
  const start = new Date(keys[0] + 'T00:00:00Z');
  start.setUTCDate(start.getUTCDate() - start.getUTCDay());       // back up to Sunday
  const end = new Date(keys[keys.length - 1] + 'T00:00:00Z');
  end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));       // forward to Saturday
  const maxCount = Math.max(...Array.from(days.values()).map(v => v.total));

  const weeks = [];
  for (let cur = new Date(start); cur <= end; ) {
    const week = [];
    for (let i = 0; i < 7; i++) {
      const key = dateKey(cur);
      week.push({ key, bucket: days.get(key) || null });
      cur.setUTCDate(cur.getUTCDate() + 1);
    }
    weeks.push(week);
  }

  const weeksHtml = weeks.map(week => {
    const cells = week.map(day => {
      if (!day.bucket) {
        return `<div class="cal-cell" data-date="${day.key}" title="${day.key}: 0 items"></div>`;
      }
      const level = Math.max(1, Math.ceil((day.bucket.total / maxCount) * 4));
      const title = `${day.key}: ${day.bucket.total} item(s) · ${day.bucket.pro}p/${day.bucket.neutral}n/${day.bucket.con}c`;
      return `<div class="cal-cell cal-has-data cal-level-${level}${day.key === activeDateFilter ? ' cal-active' : ''}" data-date="${day.key}" title="${esc(title)}"></div>`;
    }).join('');
    return `<div class="cal-week">${cells}</div>`;
  }).join('');

  const selDay = activeDateFilter ? days.get(activeDateFilter) : null;
  const selStat = selDay
    ? (() => {
        const d = selDay;
        const parts = [];
        if (d.pro     > 0) parts.push(`${d.pro} positive`);
        if (d.neutral > 0) parts.push(`${d.neutral} neutral`);
        if (d.con     > 0) parts.push(`${d.con} con`);
        return `${d.total} item${d.total !== 1 ? 's' : ''} collected${parts.length ? ' — ' + parts.join(' / ') : ''}`;
      })()
    : null;

  return `
    <div class="cal-outer">
      <div class="cal-scroll">
        <div class="cal-months">${monthLabelsHtml(weeks)}</div>
        <div class="cal-grid">${weeksHtml}</div>
      </div>
      <div class="cal-legend">
        Less
        <span class="cal-cell"></span>
        <span class="cal-cell cal-has-data cal-level-1"></span>
        <span class="cal-cell cal-has-data cal-level-2"></span>
        <span class="cal-cell cal-has-data cal-level-3"></span>
        <span class="cal-cell cal-has-data cal-level-4"></span>
        More
        ${undated ? `· ${undated} item(s) with no usable date not shown` : ''}
        ${activeDateFilter ? `<span class="cal-filter-chip" id="calClearFilter">${esc(activeDateFilter)} &times;</span>` : ''}
      </div>
      ${selStat ? `<div class="cal-date-stat">${esc(activeDateFilter)}: ${esc(selStat)}</div>` : ''}
    </div>`;
}

// ── Account-level sentiment (not just per-post) ─────────────────────────────
// Groups scored_items by author so "who's pro/con/neutral" can be answered
// about ACCOUNTS, the same way top_users answers "who's most active" —
// sentiment.py's own scoring stays per-item (a person can write both a pro
// and a con post), this just aggregates what's already in the payload.
// Memoized per activeData object since buildSnaGraph(), topUsersHtml() and
// accountSentimentHtml() all need the identical aggregation for the same
// archive and it's wasteful (and a drift risk) to recompute it three ways.
let _acctSentCache = null;
let _acctSentCacheFor = null;
function accountSentiment(d) {
  if (_acctSentCacheFor === d) return _acctSentCache;
  const accounts = {};   // handle -> { pro, neutral, con, total, name, avatar, verified, is_blue_verified, account_age_flag, account_age, account_created }
  d.scored_items.forEach(it => {
    const raw    = it.item || {};
    const handle = it.author || raw.screen_name || raw.user;
    if (!handle) return;
    if (!accounts[handle]) {
      accounts[handle] = {
        pro: 0, neutral: 0, con: 0, total: 0,
        name: raw.name || '', avatar: raw.avatar || raw.user_avatar || '',
        verified: false, is_blue_verified: false,
        // Set once at first sight, same as name/avatar above — an
        // account's creation date doesn't change between records, so
        // there's no "accumulate" logic needed the way verified has.
        account_age_flag:      raw.account_age_flag || null,
        account_age:           raw.account_age || null,
        account_created:       raw.account_created || null,
        account_age_precision: raw.account_age_precision || null,
      };
    }
    // An account can show up on both a verified and (in principle, if the
    // raw data ever disagreed across records) a non-verified item — once
    // true, stays true, same as any other identity fact aggregated here.
    if (raw.verified) accounts[handle].verified = true;
    if (raw.is_blue_verified) accounts[handle].is_blue_verified = true;
    accounts[handle][it.label]++;
    accounts[handle].total++;
  });
  _acctSentCache = accounts;
  _acctSentCacheFor = d;
  return accounts;
}

// An account's overall lean — majority label among ITS OWN scored items. A
// tie between pro and con (the case that matters most: an account posting
// equally strongly on both sides of a hot-button topic) is deliberately NOT
// broken toward either side — arbitrarily crowning one would bake a
// directional bias into exactly the accounts where the evidence is split
// down the middle. Those land in "mixed", kept distinct from "neutral"
// (which means mostly neutral-toned posts, i.e. never really took a side —
// a different thing than "took both sides equally").
function dominantLabel(a) {
  const max = Math.max(a.pro, a.neutral, a.con);
  if (max === 0) return null;
  const winners = ['pro', 'neutral', 'con'].filter(k => a[k] === max);
  return winners.length === 1 ? winners[0] : 'mixed';
}

function userRowHtml(handle, u, opts = {}) {
  const avatarUrl = isSafeImageUrl(u.avatar) ? u.avatar : '';
  const avatar = avatarUrl
    ? `<img class="user-avatar" src="${esc(avatarUrl)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
    : `<div class="user-avatar user-avatar-fallback">${esc(((u.name || handle || '?').charAt(0)).toUpperCase())}</div>`;
  const rank  = opts.rank != null ? `<span class="user-rank">${opts.rank}</span>` : '';
  const right = opts.right || '';
  return `
    <div class="user-row clickable" data-handle="${esc(handle)}">
      ${rank}
      ${avatar}
      <div class="user-identity">
        <div class="user-name">${esc(u.name || handle)}${verifiedBadgeHtml(u)}</div>
        <div class="user-handle">@${esc(handle)}${ageBadgeHtml(u)}</div>
      </div>
      ${right}
    </div>`;
}

// Auto-scroll, same reasoning as the four pro/con/neutral/mixed buckets
// below and the Items list further down — sentiment.py's top_users()/
// top_engagement() now return EVERY account/post (no backend top-N cutoff
// anymore, see sentiment.py), since this is an OSINT digital-evidence
// archive and silently dropping who/what shows up here isn't acceptable.
// Pacing how much gets built into HTML as you scroll is purely a rendering
// concern — the full arrays (topUsersList/topEngagementList) stay in
// memory the whole time, nothing is ever thrown away.
const TOP_LIST_BATCH = 20;
let topUsersFull        = [];   // every account sentiment.py returned, unfiltered
let topUsersQuery       = '';
let topUsersList        = [];   // topUsersFull, narrowed by topUsersQuery — what actually gets paginated
let topUsersShown       = 0;
let topUsersSentByHandle = null;
let topEngagementList  = [];
let topEngagementShown = 0;

function topUserRowHtml(u, i) {
  const s     = topUsersSentByHandle && topUsersSentByHandle[u.screen_name];
  const label = s ? dominantLabel(s) : null;
  const badge = label ? `<span class="sent-badge sent-${label}" style="margin-right:6px">${SENT_LABELS[label]}</span>` : '';
  return userRowHtml(u.screen_name, u, { rank: i + 1, right: `${badge}<span class="user-count">${u.count}×</span>` });
}

function userMatchText(u) {
  return [u.name || '', u.screen_name || ''].join(' ').toLowerCase();
}

function updateTopUsersStatus() {
  const status = document.getElementById('topUsersStatus');
  if (!status) return;
  const remaining = topUsersList.length - topUsersShown;
  if (remaining > 0) {
    status.style.display = '';
  } else {
    status.style.display = 'none';
  }
}

function loadMoreTopUsers() {
  if (topUsersShown >= topUsersList.length) return;
  const prev = topUsersShown;
  topUsersShown = Math.min(topUsersShown + TOP_LIST_BATCH, topUsersList.length);
  const sentinel = document.getElementById('topUsersSentinel');
  if (sentinel) sentinel.insertAdjacentHTML('beforebegin', topUsersList.slice(prev, topUsersShown).map((u, idx) => topUserRowHtml(u, prev + idx)).join(''));
  updateTopUsersStatus();
}

// Re-renders just #topUsersBox's own contents — called both on first
// render (topUsersHtml, below) and again whenever the search box's query
// changes (renderDashboard() wires that debounced listener), same split
// renderTopEngagementBox() below uses for its own search box.
function renderTopUsersBox() {
  const box = document.getElementById('topUsersBox');
  if (!box) return;
  topUsersList  = topUsersQuery ? topUsersFull.filter(u => userMatchText(u).includes(topUsersQuery)) : topUsersFull;
  topUsersShown = Math.min(TOP_LIST_BATCH, topUsersList.length);
  box.innerHTML = topUsersList.length
    ? topUsersList.slice(0, topUsersShown).map((u, i) => topUserRowHtml(u, i)).join('') + '<div id="topUsersSentinel" style="height:1px"></div>'
    : '<div class="empty-state" style="padding:16px;font-size:12px">No matches.</div>';
  const countEl = document.getElementById('topUsersCount');
  if (countEl) countEl.textContent = topUsersQuery ? `${topUsersList.length} / ${topUsersFull.length}` : `${topUsersFull.length}`;
  updateTopUsersStatus();
  rebindTopUsersObserver();
}

function topUsersHtml(users, sentByHandle) {
  topUsersFull         = users || [];
  topUsersQuery        = '';
  topUsersList         = topUsersFull;
  topUsersSentByHandle = sentByHandle;
  topUsersShown        = Math.min(TOP_LIST_BATCH, topUsersList.length);
  if (!topUsersFull.length) return '<div class="empty-state" style="padding:24px">No identifiable authors in this archive.</div>';
  return `
    <div class="user-list scrollable" id="topUsersBox">
      ${topUsersList.slice(0, topUsersShown).map((u, i) => topUserRowHtml(u, i)).join('')}
      <div id="topUsersSentinel" style="height:1px"></div>
    </div>
    <div class="load-more-note" id="topUsersStatus" style="display:none"></div>`;
}

// Explicit "which accounts are pro / con / neutral / mixed" listing — the
// SNA graph already encodes this visually (pie-per-node), but that's a
// click-to-open, hover-to-read view; this is the plain-text equivalent
// requested directly: a scannable list per bucket, sorted by how many
// scored items back that lean up (not just alphabetical).
//
// Auto-scroll rather than a hard cutoff: an archive with thousands of items
// can have thousands of unique accounts per bucket, so all four lists start
// at ACCOUNT_BATCH rows and grow by ACCOUNT_BATCH more each time you scroll
// near the bottom of that bucket's own little scroll box (same idea as
// index.html's page-level scroll-to-load-more, just scoped per box since
// four buckets share one screen). The full sorted arrays stay in
// `acctBuckets` in memory — nothing here ever throws data away, it just
// paces how much gets built into HTML at once.
const ACCOUNT_BATCH = 20;
let acctBuckets = { pro: [], con: [], neutral: [], mixed: [] };   // full sorted [handle, data] arrays
let acctShown   = { pro: 0, con: 0, neutral: 0, mixed: 0 };
const acctObservers = {};

function acctRowsHtml(key, from, to) {
  const slice = acctBuckets[key].slice(from, to);
  if (!slice.length) return from === 0 ? '<div class="empty-state" style="padding:16px;font-size:12px">None</div>' : '';
  return slice.map(([handle, a]) => userRowHtml(handle, a, {
    right: `<span class="user-breakdown">${a.pro}p · ${a.neutral}n · ${a.con}c</span>`,
  })).join('');
}

function loadMoreAccounts(key) {
  const list = acctBuckets[key];
  if (acctShown[key] >= list.length) return;
  const prev = acctShown[key];
  acctShown[key] = Math.min(acctShown[key] + ACCOUNT_BATCH, list.length);
  const sentinel = document.getElementById('asgSentinel-' + key);
  if (sentinel) sentinel.insertAdjacentHTML('beforebegin', acctRowsHtml(key, prev, acctShown[key]));
}

function accountSentimentHtml(d) {
  const accounts = accountSentiment(d);
  const buckets  = { pro: [], neutral: [], con: [], mixed: [] };
  Object.keys(accounts).forEach(handle => {
    const a     = accounts[handle];
    const label = dominantLabel(a);
    if (label) buckets[label].push([handle, a]);
  });
  Object.values(buckets).forEach(list => list.sort((x, y) => y[1].total - x[1].total));
  acctBuckets = buckets;
  acctShown   = { pro: 0, con: 0, neutral: 0, mixed: 0 };

  const sections = [
    ['pro', 'Pro'], ['con', 'Con'], ['neutral', 'Neutral'], ['mixed', 'Mixed'],
  ].map(([key, title]) => {
    const list = buckets[key];
    acctShown[key] = Math.min(ACCOUNT_BATCH, list.length);
    return `
      <div>
        <div class="asg-head"><span class="sent-badge sent-${key}">${title}</span><span class="asg-count">${list.length} account${list.length === 1 ? '' : 's'}</span></div>
        <div class="user-list scrollable" id="asgList-${key}">
          ${acctRowsHtml(key, 0, acctShown[key])}
          <div class="asg-sentinel" id="asgSentinel-${key}" style="height:1px"></div>
        </div>
      </div>`;
  }).join('');

  return `<div class="account-sent-grid">${sections}</div>`;
}

// Re-bound after every render — the scroll box + sentinel for each bucket
// are rebuilt fresh whenever an archive is opened (they're part of the
// dashboard's innerHTML), so the previous archive's observer would be
// watching a detached node. `root` is the scroll box itself (not the page),
// since these are small internally-scrolling panels, not page-level scroll.
function rebindAccountScrollObservers() {
  ['pro', 'con', 'neutral', 'mixed'].forEach(key => {
    if (acctObservers[key]) acctObservers[key].disconnect();
    const root     = document.getElementById('asgList-' + key);
    const sentinel = document.getElementById('asgSentinel-' + key);
    if (!root || !sentinel) return;
    acctObservers[key] = new IntersectionObserver((entries) => {
      if (entries.some(e => e.isIntersecting)) loadMoreAccounts(key);
    }, { root, rootMargin: '80px' });
    acctObservers[key].observe(sentinel);
  });
}

function topEngagementRowHtml(e) {
  const it     = e.item;
  const handle = it.screen_name || it.user || it.name || 'unknown';
  const text   = (it.text || it.full_text || it.post_title || it.post_text || it.description || '').trim();
  // Same itemDate()/formatItemDate() the Items list below uses — e's own
  // shape ({engagement, item}) already matches what itemDate() expects
  // (it reads `.item`), so this is the identical date, not a re-derived one.
  const dt = itemDate(e);
  const dateLabel = dt ? `<span class="item-date">${esc(formatItemDate(dt))}</span>` : '';
  return `
    <div class="eng-row">
      <div class="eng-top">
        <span class="eng-handle">@${esc(handle)}${verifiedBadgeHtml(it)}${ageBadgeHtml(it)}</span>
        ${dateLabel}
        <span class="eng-score">${e.engagement.toLocaleString()} interactions</span>
      </div>
      <div class="eng-text">${esc(text)}</div>
    </div>`;
}

function updateTopEngagementStatus() {
  const status = document.getElementById('topEngagementStatus');
  if (!status) return;
  const remaining = topEngagementList.length - topEngagementShown;
  if (remaining > 0) {
    status.style.display = '';
  } else {
    status.style.display = 'none';
  }
}

function loadMoreTopEngagement() {
  if (topEngagementShown >= topEngagementList.length) return;
  const prev = topEngagementShown;
  topEngagementShown = Math.min(topEngagementShown + TOP_LIST_BATCH, topEngagementList.length);
  const sentinel = document.getElementById('topEngagementSentinel');
  if (sentinel) sentinel.insertAdjacentHTML('beforebegin', topEngagementList.slice(prev, topEngagementShown).map(topEngagementRowHtml).join(''));
  updateTopEngagementStatus();
}

// Re-bound after every render — same reasoning as rebindAccountScrollObservers()
// just above: #topUsersBox/#topEngagementBox are rebuilt fresh whenever an
// archive is opened, so a previous archive's observer would be watching a
// detached node. `root` is each box itself (they scroll internally via
// .scrollable), not the page.
let topUsersObserver = null, topEngagementObserver = null;

// Split out from rebindTopListObservers() below — renderTopUsersBox()/
// renderTopEngagementBox() (each search box's own re-render, whenever its
// query changes) need to re-bind just their own observer to the fresh
// box/sentinel they just wrote, without touching the other one.
function rebindTopUsersObserver() {
  if (topUsersObserver) topUsersObserver.disconnect();
  const uRoot = document.getElementById('topUsersBox');
  const uSentinel = document.getElementById('topUsersSentinel');
  if (uRoot && uSentinel) {
    topUsersObserver = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) loadMoreTopUsers();
    }, { root: uRoot, rootMargin: '80px' });
    topUsersObserver.observe(uSentinel);
  }
}

function rebindTopEngagementObserver() {
  if (topEngagementObserver) topEngagementObserver.disconnect();
  const eRoot = document.getElementById('topEngagementBox');
  const eSentinel = document.getElementById('topEngagementSentinel');
  if (eRoot && eSentinel) {
    topEngagementObserver = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) loadMoreTopEngagement();
    }, { root: eRoot, rootMargin: '80px' });
    topEngagementObserver.observe(eSentinel);
  }
}

function rebindTopListObservers() {
  rebindTopUsersObserver();
  updateTopUsersStatus();

  rebindTopEngagementObserver();
  updateTopEngagementStatus();
}

// topEngagementFull is every scored item sentiment.py returned (no backend
// cap, see top_engagement()'s own docstring); topEngagementList is whatever
// subset the search box below has currently narrowed that down to — the
// same "full data stays in memory, only rendering is paced/filtered" split
// the Items browser further down uses.
let topEngagementFull  = [];
let topEngagementQuery = '';

function engagementMatchText(e) {
  const it = e.item || {};
  return [it.screen_name || it.user || it.name || '', it.text || it.full_text || it.post_title || it.post_text || it.description || '']
    .join(' ').toLowerCase();
}

function renderTopEngagementBox() {
  const box = document.getElementById('topEngagementBox');
  if (!box) return;
  topEngagementList  = topEngagementQuery
    ? topEngagementFull.filter(e => engagementMatchText(e).includes(topEngagementQuery))
    : topEngagementFull;
  topEngagementShown  = Math.min(TOP_LIST_BATCH, topEngagementList.length);
  box.innerHTML = topEngagementList.length
    ? topEngagementList.slice(0, topEngagementShown).map(topEngagementRowHtml).join('') + '<div id="topEngagementSentinel" style="height:1px"></div>'
    : '<div class="empty-state" style="padding:16px;font-size:12px">No matches.</div>';
  const countEl = document.getElementById('topEngagementCount');
  if (countEl) countEl.textContent = topEngagementQuery ? `${topEngagementList.length} / ${topEngagementFull.length}` : `${topEngagementFull.length}`;
  updateTopEngagementStatus();
  rebindTopEngagementObserver();
}

function topEngagementHtml(items) {
  topEngagementFull   = items || [];
  topEngagementQuery  = '';
  topEngagementList   = topEngagementFull;
  topEngagementShown  = Math.min(TOP_LIST_BATCH, topEngagementList.length);
  if (!topEngagementFull.length) return '<div class="empty-state" style="padding:24px">No engagement data (reply/retweet/favorite counts) in this archive.</div>';
  return `
    <div class="engagement-list scrollable" id="topEngagementBox">
      ${topEngagementList.slice(0, topEngagementShown).map(topEngagementRowHtml).join('')}
      <div id="topEngagementSentinel" style="height:1px"></div>
    </div>
    <div class="load-more-note" id="topEngagementStatus" style="display:none"></div>`;
}

function wordCloudHtml(words) {
  if (!words || !words.length) return '<div class="empty-state" style="padding:24px">Not enough text to build a word list.</div>';
  const max = Math.max(...words.map(w => w.count));
  const min = Math.min(...words.map(w => w.count));
  const span = Math.max(1, max - min);
  return '<div class="wordcloud">' + words.map(w => {
    // Font size scaled by frequency (13px..38px) — size is the encoding here,
    // not color, so this stays legible without inventing a new color ramp.
    const t = (w.count - min) / span;
    const size = Math.round(13 + t * 25);
    const opacity = (0.55 + t * 0.45).toFixed(2);
    return `<span class="wc-word" style="font-size:${size}px;opacity:${opacity}" title="${w.count}×">${esc(w.word)}</span>`;
  }).join('') + '</div>';
}

const ITEM_BATCH = 30;
let filteredItems   = [];
let itemRenderCount = 0;

function itemCardHtml(it) {
  const matches = (it.matches || []).map(m =>
    `<span class="match-chip mc-${m.polarity}">${esc(m.word)}</span>`
  ).join('');
  const scoreLabel = typeof it.confidence === 'number'
    ? `${Math.round(it.confidence * 100)}% confidence`
    : `score ${it.score > 0 ? '+' : ''}${it.score}`;
  const dt = itemDate(it);
  const dateLabel = dt ? `<span class="item-date">${esc(formatItemDate(dt))}</span>` : '';
  return `
    <div class="item-card">
      <div class="item-card-top">
        <span class="item-author">${it.author ? '@' + esc(it.author) : 'Unknown'}${verifiedBadgeHtml(it.item)}${ageBadgeHtml(it.item)}</span>
        <span class="sent-badge sent-${it.label}">${SENT_LABELS[it.label]}</span>
        ${dateLabel}
        <span class="item-score">${scoreLabel}</span>
      </div>
      <div class="item-text">${esc(it.text)}</div>
      ${matches ? `<div class="item-matches">${matches}</div>` : ''}
    </div>`;
}

function updateItemListStatus() {
  const status = document.getElementById('itemListStatus');
  if (!status) return;
  const remaining = filteredItems.length - itemRenderCount;
  if (remaining > 0) {
    status.style.display = '';
  } else {
    status.style.display = 'none';
  }
}

function loadMoreItems() {
  if (itemRenderCount >= filteredItems.length) return;
  const prev = itemRenderCount;
  itemRenderCount = Math.min(itemRenderCount + ITEM_BATCH, filteredItems.length);
  document.getElementById('itemList').insertAdjacentHTML(
    'beforeend',
    filteredItems.slice(prev, itemRenderCount).map(itemCardHtml).join(''),
  );
  updateItemListStatus();
}

function renderItemList() {
  const d = activeData;
  const q = (document.getElementById('itemSearch').value || '').toLowerCase().trim();

  let items = d.scored_items;
  if (activeFilter) items = items.filter(it => it.label === activeFilter);
  if (activeDateFilter) items = items.filter(it => { const dt = itemDate(it); return dt && dateKey(dt) === activeDateFilter; });
  if (q) items = items.filter(it => (it.text || '').toLowerCase().includes(q) || (it.author || '').toLowerCase().includes(q));

  document.getElementById('itemCount').textContent = `${items.length} / ${d.scored_items.length}`;

  filteredItems   = items;
  itemRenderCount = Math.min(ITEM_BATCH, items.length);

  const list = document.getElementById('itemList');
  if (!items.length) {
    list.innerHTML = '<div class="empty-state" style="padding:24px">No items match.</div>';
    updateItemListStatus();
    return;
  }

  list.innerHTML = items.slice(0, itemRenderCount).map(itemCardHtml).join('');
  updateItemListStatus();
}

// root: null (page viewport) — same reasoning as index.html's own load-more
// observer: the item list is never height-constrained itself, the page is
// what actually scrolls. Re-observed (not just created once) after every
// render since #itemListSentinel is rebuilt along with the rest of the
// dashboard each time a different archive is opened.
const itemListObserver = new IntersectionObserver((entries) => {
  if (entries.some(e => e.isIntersecting)) loadMoreItems();
}, { root: null, rootMargin: '300px' });

loadSidebar();
})();
</script>
</body>
</html>
