N
Nexus API Referencev2.4.1

Evaluation Results Dashboard

Standalone HTML page for reviewing evaluation results from any CSV with a Note column and one or more `<name> Score` / `<name> Feedback` column pairs. Supports score filtering, sort order, full-text search, multi-tag filtering (optional `Tags` column), and expandable per-dimension feedback sections.

GET/v2/utils/evaluation-results-dashboardRequires authentication

Code Example

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Evaluation Results Dashboard</title>
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'">
  <style>
    :root {
      --bg: #0f1117;
      --surface: #161922;
      --surface2: #1e2235;
      --surface3: #252a40;
      --border: #2a2f4a;
      --text: #e2e8f0;
      --muted: #7c87a0;
      --dim: #4a5270;
      --accent: #6366f1;
      --accent-hover: #818cf8;
      --accent-glow: rgba(99,102,241,0.12);
    }
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html, body { height: 100%; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
      background: var(--bg); color: var(--text);
      display: flex; flex-direction: column; overflow: hidden; height: 100vh;
    }

    /* ── UPLOAD SCREEN ─────────────────────────────── */
    #upload-screen {
      flex: 1; display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 0;
      padding: 40px;
    }
    #upload-screen.hidden { display: none; }

    .upload-card {
      background: var(--surface); border: 2px dashed var(--border);
      border-radius: 20px; padding: 56px 72px; text-align: center;
      max-width: 500px; width: 100%;
      transition: border-color .2s, background .2s;
    }
    .upload-card.drag { border-color: var(--accent); background: var(--accent-glow); }
    .u-icon { font-size: 48px; margin-bottom: 18px; }
    .u-title { font-size: 22px; font-weight: 700; letter-spacing: -.02em; margin-bottom: 8px; }
    .u-sub {
      font-size: 13px; color: var(--muted); line-height: 1.6;
      margin-bottom: 28px;
    }
    .u-hint { font-size: 12px; color: var(--dim); margin-top: 18px; }

    /* ── MAIN APP ─────────────────────────────────── */
    #app { display: none; flex: 1; flex-direction: column; overflow: hidden; }
    #app.visible { display: flex; }

    /* ── TOP BAR ──────────────────────────────────── */
    .topbar {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 11px 20px; display: flex; align-items: center; gap: 12px;
      flex-shrink: 0;
    }
    .topbar-title { font-size: 15px; font-weight: 700; letter-spacing: -.02em; }
    .topbar-sep { color: var(--dim); }
    .topbar-file { font-size: 13px; color: var(--muted); }
    .spacer { flex: 1; }
    .chip {
      font-size: 12px; color: var(--muted);
      background: var(--surface2); border: 1px solid var(--border);
      padding: 4px 10px; border-radius: 20px;
    }
    .chip strong { color: var(--text); }

    /* ── CONTROLS ─────────────────────────────────── */
    .controls {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 8px 20px; display: flex; align-items: center; gap: 14px;
      flex-shrink: 0; flex-wrap: wrap;
    }
    .cg { display: flex; align-items: center; gap: 6px; }
    .cl { font-size: 12px; color: var(--muted); white-space: nowrap; }

    input[type="range"] {
      -webkit-appearance: none; appearance: none;
      width: 80px; height: 4px; background: var(--surface3);
      border-radius: 2px; outline: none; cursor: pointer;
    }
    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none; width: 14px; height: 14px;
      border-radius: 50%; background: var(--accent); cursor: pointer;
    }
    .rv { font-size: 12px; color: var(--accent-hover); font-weight: 600; min-width: 28px; text-align: center; }

    select {
      background: var(--surface2); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px; padding: 5px 8px; font-size: 13px; cursor: pointer;
    }
    .search {
      background: var(--surface2); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px; padding: 5px 10px; font-size: 13px;
      width: 190px; outline: none; transition: border-color .15s;
    }
    .search:focus { border-color: var(--accent); }
    .search::placeholder { color: var(--dim); }
    .filter-stat { font-size: 12px; color: var(--accent-hover); }

    /* ── BUTTONS ──────────────────────────────────── */
    .btn {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 7px 14px; border-radius: 7px;
      border: 1px solid var(--border); background: var(--surface2);
      color: var(--text); font-size: 13px; font-weight: 500;
      cursor: pointer; transition: all .15s; white-space: nowrap;
    }
    .btn:hover { border-color: var(--accent); color: var(--accent-hover); }
    .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
    .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
    .btn-sm { padding: 5px 10px; font-size: 13px; }
    .btn:disabled { opacity: .3; cursor: not-allowed; border-color: var(--border); color: var(--muted); }
    #file-input { display: none; }

    /* ── MAIN SPLIT ───────────────────────────────── */
    .split { display: flex; flex: 1; min-height: 0; overflow: hidden; }

    /* ── LEFT PANEL ───────────────────────────────── */
    .left {
      width: 340px; flex-shrink: 0;
      border-right: 1px solid var(--border);
      display: flex; flex-direction: column; overflow: hidden;
    }
    .left-hdr {
      padding: 9px 16px; flex-shrink: 0;
      font-size: 11px; font-weight: 700; text-transform: uppercase;
      letter-spacing: .08em; color: var(--muted);
      border-bottom: 1px solid var(--border);
    }
    .list { overflow-y: auto; flex: 1; min-height: 0; }

    .item {
      padding: 11px 16px; border-bottom: 1px solid var(--border);
      cursor: pointer; transition: background .1s;
      display: flex; align-items: flex-start; gap: 9px;
    }
    .item:hover { background: var(--surface2); }
    .item.active {
      background: rgba(99,102,241,.1);
      border-left: 3px solid var(--accent);
      padding-left: 13px;
    }
    .item-num { font-size: 11px; color: var(--dim); min-width: 18px; padding-top: 2px; }
    .item-body { flex: 1; min-width: 0; }
    .item-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
    .item-preview {
      font-size: 13px; line-height: 1.4; color: var(--text);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      flex: 1; min-width: 0;
    }
    .badge {
      font-size: 11px; font-weight: 700; padding: 2px 8px;
      border-radius: 20px; display: inline-block; flex-shrink: 0;
    }
    .item-bottom { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .dots { display: flex; align-items: center; gap: 3px; flex-shrink: 0; }
    .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
    .dots-more { font-size: 10px; color: var(--muted); margin-left: 2px; }

    .item-tags { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
    .item-tag {
      font-size: 10px; padding: 1px 7px; border-radius: 10px;
      background: var(--surface3); color: var(--muted);
      white-space: nowrap; max-width: 120px;
      overflow: hidden; text-overflow: ellipsis;
    }
    .item-tag.on { background: var(--accent-glow); color: var(--accent-hover); }
    .item-tag.more { background: transparent; padding: 1px 4px; max-width: none; }

    /* Tag filter bar */
    .tag-filter {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 7px 20px; display: none; align-items: center; gap: 10px;
      flex-shrink: 0; flex-wrap: wrap;
    }
    .tag-filter.visible { display: flex; }
    .tag-filter-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
    .match-toggle {
      display: inline-flex; border: 1px solid var(--border); border-radius: 10px;
      overflow: hidden; font-size: 11px;
    }
    .match-opt {
      background: transparent; border: none; color: var(--muted);
      padding: 3px 10px; cursor: pointer; transition: all .15s; font: inherit;
    }
    .match-opt:hover { color: var(--text); }
    .match-opt.active { background: var(--accent); color: #fff; }
    .tag-chips { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
    .chip-filter {
      font-size: 11px; padding: 3px 10px; border-radius: 12px;
      background: var(--surface2); color: var(--muted);
      border: 1px solid var(--border);
      cursor: pointer; user-select: none; transition: all .15s;
      white-space: nowrap; max-width: 180px;
      overflow: hidden; text-overflow: ellipsis;
      display: inline-flex; align-items: center;
    }
    .chip-filter .label { overflow: hidden; text-overflow: ellipsis; }
    .chip-filter:hover { border-color: var(--accent); color: var(--text); }
    .chip-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; }
    .chip-filter .count { opacity: .6; margin-left: 4px; }
    .tag-clear, .tag-toggle {
      font-size: 11px; padding: 3px 10px; border-radius: 12px;
      background: transparent; border: 1px solid var(--border); color: var(--muted);
      cursor: pointer; transition: all .15s; white-space: nowrap;
    }
    .tag-clear:hover, .tag-toggle:hover { color: var(--text); border-color: var(--accent); }

    .no-results { padding: 32px 20px; text-align: center; color: var(--muted); font-size: 13px; }

    /* ── RIGHT PANEL ──────────────────────────────── */
    .right { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }

    .empty {
      height: 100%;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      gap: 10px; color: var(--muted);
    }
    .empty-icon { font-size: 32px; opacity: .4; }
    .empty-title { font-size: 15px; font-weight: 500; }
    .empty-sub { font-size: 13px; opacity: .7; }

    /* Detail header */
    .detail-hdr {
      position: sticky; top: 0; z-index: 10;
      padding: 13px 22px; border-bottom: 1px solid var(--border);
      display: flex; align-items: center; gap: 10px;
      background: var(--surface);
    }
    .detail-title { font-size: 13px; color: var(--muted); flex: 1; }
    .detail-title strong { color: var(--text); font-size: 14px; }
    .avg-badge {
      font-size: 20px; font-weight: 800; letter-spacing: -.02em;
      padding: 4px 13px; border-radius: 9px;
    }
    .avg-label { font-size: 11px; font-weight: 500; opacity: .6; }
    .nav { padding: 5px 9px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); font-size: 16px; cursor: pointer; transition: all .15s; line-height: 1; }
    .nav:hover { border-color: var(--accent); }
    .nav:disabled { opacity: .25; cursor: not-allowed; }

    /* Detail body */
    .detail-body {
      padding: 22px;
    }
    .detail-body > * + * { margin-top: 16px; }

    /* Note box */
    .note-box {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
    }
    .box-hdr {
      padding: 11px 18px; display: flex; align-items: center; justify-content: space-between;
      cursor: pointer; user-select: none; border-bottom: 1px solid var(--border);
      transition: background .15s;
    }
    .box-hdr:hover { background: var(--surface2); }
    .box-label {
      font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted);
    }
    .chev { font-size: 10px; color: var(--dim); transition: transform .2s; }
    .chev.open { transform: rotate(180deg); }
    .note-body { padding: 18px 20px; }
    .note-body.gone { display: none; }

    .note-heading { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 12px; line-height: 1.35; overflow-wrap: anywhere; }
    .note-p { font-size: 14px; line-height: 1.75; color: #b8c5d8; margin-bottom: 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
    .note-p:last-child { margin-bottom: 0; }

    /* Scores grid */
    .scores-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
    .score-card {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
      padding: 15px 12px; text-align: center;
    }
    .sc-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 8px; }
    .sc-value { font-size: 28px; font-weight: 800; letter-spacing: -.02em; line-height: 1; margin-bottom: 10px; }
    .sc-track { height: 4px; background: var(--surface3); border-radius: 2px; overflow: hidden; }
    .sc-fill { height: 100%; border-radius: 2px; }

    /* Feedback sections */
    .fb-section {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
    }
    .fb-hdr {
      padding: 12px 17px; display: flex; align-items: center; gap: 10px;
      cursor: pointer; user-select: none; transition: background .15s;
    }
    .fb-hdr:hover { background: var(--surface2); }
    .fb-name { font-size: 14px; font-weight: 600; flex: 1; }
    .fb-score { font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 20px; }
    .fb-body {
      padding: 14px 17px; border-top: 1px solid var(--border);
      font-size: 14px; line-height: 1.68; color: #a8b5cc;
      white-space: pre-wrap; overflow-wrap: anywhere;
    }
    .fb-body.gone { display: none; }
    .fb-empty { font-style: italic; opacity: .5; }

    /* Scrollbar */
    ::-webkit-scrollbar { width: 10px; height: 10px; }
    ::-webkit-scrollbar-track { background: transparent; }
    ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
    ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
  </style>
</head>
<body>

<!-- ── UPLOAD SCREEN ──────────────────────────────────── -->
<div id="upload-screen">
  <div class="upload-card" id="drop-zone">
    <div class="u-icon">📊</div>
    <div class="u-title">Evaluation Dashboard</div>
    <div class="u-sub">
      Load a CSV with evaluation results to explore scores<br>
      and LLM-as-a-judge feedback across all dimensions.
    </div>
    <button class="btn btn-primary" onclick="document.getElementById('file-input').click()">
      Upload CSV file
    </button>
    <div class="u-hint">Expected columns: a <code>Note</code> column, plus any number of <code>&lt;name&gt; Score</code> / <code>&lt;name&gt; Feedback</code> pairs. <code>Average Score</code> and <code>Tags</code> (comma-separated) are optional.</div>
  </div>
</div>

<!-- ── MAIN APP ───────────────────────────────────────── -->
<div id="app">

  <!-- Top bar -->
  <div class="topbar">
    <span class="topbar-title">Evaluation Dashboard</span>
    <span class="topbar-sep">·</span>
    <span class="topbar-file" id="file-label">—</span>
    <div class="spacer"></div>
    <div class="chip" id="stat-count">— evals</div>
    <div class="chip" id="stat-avg">Avg —</div>
    <button class="btn btn-sm" onclick="document.getElementById('file-input').click()">Load CSV</button>
  </div>

  <!-- Controls -->
  <div class="controls">
    <div class="cg">
      <span class="cl">Min</span>
      <input type="range" id="sl-min" min="0" max="100" value="0">
      <span class="rv" id="lbl-min">0</span>
    </div>
    <div class="cg">
      <span class="cl">Max</span>
      <input type="range" id="sl-max" min="0" max="100" value="100">
      <span class="rv" id="lbl-max">100</span>
    </div>
    <div class="cg">
      <span class="cl">Sort</span>
      <select id="sort-sel">
        <option value="desc">Highest first</option>
        <option value="asc">Lowest first</option>
        <option value="orig">Original order</option>
      </select>
    </div>
    <div class="cg">
      <input class="search" id="search" type="text" placeholder="Search notes…">
    </div>
    <div class="spacer"></div>
    <span class="filter-stat" id="filter-stat"></span>
  </div>

  <!-- Tag filter (only shown when CSV has tags) -->
  <div class="tag-filter" id="tag-filter">
    <span class="tag-filter-label">Tags</span>
    <div class="match-toggle" id="match-toggle" role="group" aria-label="Match mode">
      <button class="match-opt active" data-mode="any">Any</button>
      <button class="match-opt" data-mode="all">All</button>
    </div>
    <div class="tag-chips" id="tag-chips"></div>
    <button class="tag-toggle" id="tag-toggle" style="display:none;"></button>
    <button class="tag-clear" id="tag-clear" style="display:none;">Clear</button>
  </div>

  <!-- Split view -->
  <div class="split">

    <!-- Left: list -->
    <div class="left">
      <div class="left-hdr" id="list-hdr">Results</div>
      <div class="list" id="list"></div>
    </div>

    <!-- Right: detail -->
    <div class="right">
      <div class="empty" id="empty">
        <div class="empty-icon">←</div>
        <div class="empty-title">Select an evaluation</div>
        <div class="empty-sub">Click any item in the list to view it</div>
      </div>
      <div class="detail" id="detail" style="display:none;"></div>
    </div>

  </div>
</div>

<input type="file" id="file-input" accept=".csv">

<script>
// ── State ────────────────────────────────────────────────
let evals = [], filtered = [], selectedId = null;
let minScore = 0, maxScore = 100, sortOrder = 'desc', query = '';
let activeTags = new Set();
let tagMatchMode = 'any';
let tagsExpanded = false;
let collapsed = { note: false };
const MAX_DOTS = 5;
const MAX_ITEM_TAGS = 3;
const MAX_FILTER_CHIPS = 15;

// ── CSV Parser ───────────────────────────────────────────
function parseCSV(txt) {
  const rows = [];
  let i = 0, n = txt.length;
  while (i < n) {
    while (i < n && (txt[i] === '\r' || txt[i] === '\n')) i++;
    if (i >= n) break;
    const row = [];
    while (i < n) {
      let f = '';
      if (txt[i] === '"') {
        i++;
        while (i < n) {
          if (txt[i] === '"' && txt[i+1] === '"') { f += '"'; i += 2; }
          else if (txt[i] === '"') { i++; break; }
          else f += txt[i++];
        }
      } else {
        while (i < n && txt[i] !== ',' && txt[i] !== '\r' && txt[i] !== '\n') f += txt[i++];
      }
      row.push(f.trim());
      if (i < n && txt[i] === ',') i++;
      else { if (txt[i] === '\r') i++; if (txt[i] === '\n') i++; break; }
    }
    if (row.some(c => c)) rows.push(row);
  }
  return rows;
}

function detectCols(hdr) {
  const norm = s => s.toLowerCase().replace(/[_\-]+/g, ' ').trim();
  const h = hdr.map(norm);
  const findAll = pred => h.map((c, i) => pred(c, i) ? i : -1).filter(i => i !== -1);
  const firstOf = (...preds) => { for (const p of preds) { const i = h.findIndex(p); if (i !== -1) return i; } return -1; };

  const noteIdx = firstOf(c => c === 'note', c => c.includes('note'));
  const avgIdx  = firstOf(c => c === 'average score', c => c === 'average' || c === 'avg', c => c.includes('average'));
  const tagsIdx = firstOf(c => c === 'tags' || c === 'labels' || c === 'categories', c => /\btags?\b/.test(c) || /\blabels?\b/.test(c));

  const reserved = new Set([noteIdx, avgIdx, tagsIdx].filter(i => i !== -1));

  const scoreIdxs = findAll((c, i) => !reserved.has(i) && /\s+score$/.test(c));
  const dims = [];
  const used = new Set(reserved);
  for (const i of scoreIdxs) {
    const name = h[i].replace(/\s+score$/, '').trim();
    if (!name || name === 'average' || name === 'avg') continue;
    const fbIdx = h.findIndex((c, j) => !used.has(j) && (c === `${name} feedback` || c === `${name} comment`));
    const key = name.replace(/\s+/g, '-');
    const label = name.split(/\s+/).map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
    dims.push({ key, label, scIdx: i, fbIdx });
    used.add(i);
    if (fbIdx !== -1) used.add(fbIdx);
  }

  return { noteIdx: noteIdx !== -1 ? noteIdx : 0, avgIdx, tagsIdx, dims };
}

function parseTags(raw) {
  if (!raw) return [];
  const seen = new Set();
  const out = [];
  for (const t of raw.split(/[,|;]/)) {
    const v = t.trim();
    if (v && !seen.has(v)) { seen.add(v); out.push(v); }
  }
  return out;
}

function buildEvals(rows) {
  if (rows.length < 2) return [];
  const c = detectCols(rows[0]);
  const g = (row, i) => (i >= 0 && i < row.length) ? row[i] : '';
  const n = v => { const x = parseFloat(v); return isNaN(x) ? 0 : Math.round(x); };
  return rows.slice(1).map((row, i) => {
    const dims = c.dims.map(d => ({
      key: d.key,
      label: d.label,
      score: n(g(row, d.scIdx)),
      feedback: g(row, d.fbIdx),
    }));
    let avg = n(g(row, c.avgIdx));
    if (!avg && dims.some(d => d.score))
      avg = Math.round(dims.reduce((s, d) => s + d.score, 0) / dims.length);
    const tags = parseTags(g(row, c.tagsIdx));
    return { id: i, note: g(row, c.noteIdx), dims, avg, tags };
  }).filter(e => e.note.trim());
}

// ── Score helpers ────────────────────────────────────────
function sColor(s) {
  if (s >= 80) return '#22c55e';
  if (s >= 50) return '#f59e0b';
  return '#ef4444';
}
function sBg(s) {
  if (s >= 80) return 'rgba(34,197,94,.15)';
  if (s >= 50) return 'rgba(245,158,11,.15)';
  return 'rgba(239,68,68,.15)';
}

// ── Note formatting ──────────────────────────────────────
function esc(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
  return esc(s).replace(/"/g,'&quot;');
}

function notePreview(note) {
  const line = note.split('\n')[0].trim().replace(/^\*{0,2}(title|note)[:\s*]*/i,'').replace(/\*+$/,'').trim();
  return line.length > 72 ? line.slice(0, 70) + '…' : (line || note.slice(0, 72));
}

function noteHTML(note) {
  const parts = note.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
  if (!parts.length) return `<p class="note-p">${esc(note)}</p>`;
  const first = parts[0];
  const isTitle = parts.length > 1 && first.length < 130 && !/\.\s*$/.test(first);
  let out = '';
  if (isTitle) {
    const t = first.replace(/^\*{1,2}|\*{1,2}$/g,'').replace(/^(title|note|heading)[:\s]*/i,'').trim();
    out += `<div class="note-heading">${esc(t)}</div>`;
    out += parts.slice(1).map(p => `<p class="note-p">${esc(p)}</p>`).join('');
  } else {
    out += parts.map(p => `<p class="note-p">${esc(p)}</p>`).join('');
  }
  return out;
}

// ── Filter & sort ────────────────────────────────────────
function applyFilters() {
  const q = query.toLowerCase();
  filtered = evals.filter(e => {
    if (e.avg < minScore || e.avg > maxScore) return false;
    if (q && !e.note.toLowerCase().includes(q)) return false;
    if (activeTags.size > 0) {
      if (tagMatchMode === 'all') {
        for (const t of activeTags) if (!e.tags.includes(t)) return false;
      } else {
        if (!e.tags.some(t => activeTags.has(t))) return false;
      }
    }
    return true;
  });
  if (sortOrder === 'desc') filtered.sort((a, b) => b.avg - a.avg);
  else if (sortOrder === 'asc') filtered.sort((a, b) => a.avg - b.avg);
  renderList();
  updateStats();
}

// ── Render list ──────────────────────────────────────────
function renderList() {
  const el = document.getElementById('list');
  if (!filtered.length) {
    el.innerHTML = '<div class="no-results">No evaluations match the current filters.</div>';
    return;
  }
  el.innerHTML = filtered.map((e, rank) => {
    const active = e.id === selectedId ? 'active' : '';
    const c = sColor(e.avg), bg = sBg(e.avg);
    const shown = e.dims.slice(0, MAX_DOTS);
    const extra = e.dims.length - shown.length;
    const dotsHtml = shown.map(d =>
      `<div class="dot" title="${escAttr(d.label)}: ${d.score}" style="background:${sColor(d.score)}"></div>`
    ).join('') + (extra > 0 ? `<span class="dots-more">+${extra}</span>` : '');
    const shownTags = e.tags.slice(0, MAX_ITEM_TAGS);
    const extraTags = e.tags.length - shownTags.length;
    const tagsHtml = e.tags.length ? `<div class="item-tags">${
      shownTags.map(t => `<span class="item-tag${activeTags.has(t)?' on':''}" title="${escAttr(t)}">${esc(t)}</span>`).join('') +
      (extraTags > 0 ? `<span class="item-tag more" title="${escAttr(e.tags.slice(MAX_ITEM_TAGS).join(', '))}">+${extraTags}</span>` : '')
    }</div>` : '';
    return `<div class="item ${active}" id="item-${e.id}" onclick="select(${e.id})">
      <div class="item-num">${rank+1}</div>
      <div class="item-body">
        <div class="item-top">
          <div class="item-preview">${esc(notePreview(e.note))}</div>
          <span class="badge" style="color:${c};background:${bg}">${e.avg}</span>
        </div>
        <div class="item-bottom">
          <div class="dots">${dotsHtml}</div>
          ${tagsHtml}
        </div>
      </div>
    </div>`;
  }).join('');
}

function renderTagFilter() {
  const bar = document.getElementById('tag-filter');
  const chips = document.getElementById('tag-chips');
  const clear = document.getElementById('tag-clear');
  const toggle = document.getElementById('tag-toggle');

  const counts = new Map();
  for (const e of evals) for (const t of e.tags) counts.set(t, (counts.get(t) || 0) + 1);

  if (counts.size === 0) { bar.classList.remove('visible'); chips.innerHTML = ''; return; }

  // Active tags first (so they stay visible even when collapsed), then by frequency, then alpha
  const sorted = [...counts.entries()].sort((a, b) => {
    const aA = activeTags.has(a[0]) ? 1 : 0, bA = activeTags.has(b[0]) ? 1 : 0;
    if (aA !== bA) return bA - aA;
    return b[1] - a[1] || a[0].localeCompare(b[0]);
  });
  const visible = tagsExpanded ? sorted : sorted.slice(0, MAX_FILTER_CHIPS);

  chips.innerHTML = visible.map(([t, n]) =>
    `<span class="chip-filter${activeTags.has(t)?' active':''}" data-tag="${escAttr(t)}" title="${escAttr(t)}"><span class="label">${esc(t)}</span><span class="count">${n}</span></span>`
  ).join('');

  if (sorted.length > MAX_FILTER_CHIPS) {
    toggle.style.display = 'inline-block';
    toggle.textContent = tagsExpanded ? 'Show less' : `Show all (${sorted.length})`;
  } else {
    toggle.style.display = 'none';
  }

  clear.style.display = activeTags.size > 0 ? 'inline-block' : 'none';
  bar.classList.add('visible');
}

window.toggleTag = function(tag) {
  if (activeTags.has(tag)) activeTags.delete(tag);
  else activeTags.add(tag);
  renderTagFilter();
  applyFilters();
};

window.clearTags = function() {
  activeTags.clear();
  renderTagFilter();
  applyFilters();
};

function updateStats() {
  const n = evals.length;
  const avg = n ? Math.round(evals.reduce((s,e)=>s+e.avg,0)/n) : 0;
  document.getElementById('stat-count').innerHTML = `<strong>${n}</strong> evals`;
  document.getElementById('stat-avg').innerHTML = `Avg <strong style="color:${sColor(avg)}">${avg}</strong>`;
  const fn = filtered.length;
  document.getElementById('filter-stat').textContent = fn < n ? `Showing ${fn} of ${n}` : '';
}

// ── Select & detail ──────────────────────────────────────
window.select = function(id) {
  selectedId = id;
  renderList();
  const e = evals.find(x => x.id === id);
  if (!e) return;

  document.getElementById('empty').style.display = 'none';
  const det = document.getElementById('detail');
  det.style.display = 'block';

  const fi = filtered.findIndex(x => x.id === id);
  const prevId = fi > 0 ? filtered[fi-1].id : null;
  const nextId = fi < filtered.length-1 ? filtered[fi+1].id : null;

  const dims = e.dims;
  dims.forEach(d => { if (!(d.key in collapsed)) collapsed[d.key] = false; });

  const ac = sColor(e.avg), ab = sBg(e.avg);

  det.innerHTML = `
    <div class="detail-hdr">
      <div class="detail-title">Evaluation <strong>#${id+1}</strong></div>
      <div class="avg-badge" style="color:${ac};background:${ab}">
        ${e.avg} <span class="avg-label">avg</span>
      </div>
      <button class="nav" onclick="select(${prevId})" ${prevId===null?'disabled':''}>‹</button>
      <button class="nav" onclick="select(${nextId})" ${nextId===null?'disabled':''}>›</button>
    </div>

    <div class="detail-body">

      <!-- Note text -->
      <div class="note-box">
        <div class="box-hdr" onclick="toggle('note',this)">
          <span class="box-label">Generated Note</span>
          <span class="chev ${collapsed.note?'':'open'}">▼</span>
        </div>
        <div class="note-body ${collapsed.note?'gone':''}">
          ${noteHTML(e.note)}
        </div>
      </div>

      <!-- Scores overview -->
      <div class="scores-grid">
        ${dims.map(d => {
          const c = sColor(d.score);
          return `<div class="score-card">
            <div class="sc-label">${d.label}</div>
            <div class="sc-value" style="color:${c}">${d.score}</div>
            <div class="sc-track"><div class="sc-fill" style="width:${d.score}%;background:${c}"></div></div>
          </div>`;
        }).join('')}
      </div>

      <!-- Feedback sections -->
      ${dims.map(d => {
        const c = sColor(d.score), bg = sBg(d.score);
        const open = !collapsed[d.key];
        return `<div class="fb-section">
          <div class="fb-hdr" onclick="toggle('${d.key}',this)">
            <span class="fb-name">${d.label} Feedback</span>
            <span class="fb-score" style="color:${c};background:${bg}">${d.score}/100</span>
            <span class="chev ${open?'open':''}">▼</span>
          </div>
          <div class="fb-body ${open?'':'gone'}">${d.feedback ? esc(d.feedback) : '<span class="fb-empty">No feedback provided.</span>'}</div>
        </div>`;
      }).join('')}

    </div>
  `;

  document.getElementById(`item-${id}`)?.scrollIntoView({ block: 'nearest' });
};

window.toggle = function(key, hdr) {
  collapsed[key] = !collapsed[key];
  hdr.querySelector('.chev').classList.toggle('open');
  hdr.nextElementSibling.classList.toggle('gone');
};

// ── Load CSV ─────────────────────────────────────────────
function loadCSV(txt, filename) {
  const rows = parseCSV(txt);
  evals = buildEvals(rows);
  selectedId = null;
  collapsed = { note: false };
  activeTags = new Set();
  tagMatchMode = 'any';
  tagsExpanded = false;
  document.querySelectorAll('.match-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === 'any'));

  document.getElementById('upload-screen').classList.add('hidden');
  document.getElementById('app').classList.add('visible');
  document.getElementById('file-label').textContent = filename || 'loaded';
  document.getElementById('empty').style.display = 'flex';
  document.getElementById('detail').style.display = 'none';

  renderTagFilter();
  applyFilters();

  if (filtered.length) select(filtered[0].id);
}

// ── Events ───────────────────────────────────────────────
document.getElementById('file-input').addEventListener('change', e => {
  const f = e.target.files[0]; if (!f) return;
  const r = new FileReader();
  r.onload = ev => loadCSV(ev.target.result, f.name);
  r.readAsText(f);
  e.target.value = '';
});

const dz = document.getElementById('drop-zone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
dz.addEventListener('drop', e => {
  e.preventDefault(); dz.classList.remove('drag');
  const f = e.dataTransfer.files[0];
  if (!f || !f.name.toLowerCase().endsWith('.csv')) return;
  const r = new FileReader();
  r.onload = ev => loadCSV(ev.target.result, f.name);
  r.readAsText(f);
});

document.getElementById('sl-min').addEventListener('input', e => {
  minScore = +e.target.value;
  if (minScore > maxScore) { maxScore = minScore; document.getElementById('sl-max').value = maxScore; document.getElementById('lbl-max').textContent = maxScore; }
  document.getElementById('lbl-min').textContent = minScore;
  applyFilters();
});
document.getElementById('sl-max').addEventListener('input', e => {
  maxScore = +e.target.value;
  if (maxScore < minScore) { minScore = maxScore; document.getElementById('sl-min').value = minScore; document.getElementById('lbl-min').textContent = minScore; }
  document.getElementById('lbl-max').textContent = maxScore;
  applyFilters();
});
document.getElementById('sort-sel').addEventListener('change', e => { sortOrder = e.target.value; applyFilters(); });
document.getElementById('search').addEventListener('input', e => { query = e.target.value; applyFilters(); });

document.getElementById('tag-chips').addEventListener('click', e => {
  const chip = e.target.closest('.chip-filter');
  if (chip && chip.dataset.tag) toggleTag(chip.dataset.tag);
});
document.getElementById('tag-clear').addEventListener('click', clearTags);
document.getElementById('tag-toggle').addEventListener('click', () => {
  tagsExpanded = !tagsExpanded;
  renderTagFilter();
});
document.getElementById('match-toggle').addEventListener('click', e => {
  const btn = e.target.closest('.match-opt');
  if (!btn || btn.classList.contains('active')) return;
  tagMatchMode = btn.dataset.mode;
  document.querySelectorAll('.match-opt').forEach(b => b.classList.toggle('active', b === btn));
  applyFilters();
});

document.addEventListener('keydown', e => {
  if (!filtered.length || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
  const i = filtered.findIndex(x => x.id === selectedId);
  if ((e.key === 'ArrowDown' || e.key === 'j') && i < filtered.length-1) { e.preventDefault(); select(filtered[i+1].id); }
  if ((e.key === 'ArrowUp'   || e.key === 'k') && i > 0)                 { e.preventDefault(); select(filtered[i-1].id); }
});
</script>
</body>
</html>
Last updated: April 22, 2026html