"""PR Lifecycle dashboard — single-page view of every PR through the pipeline.

Sortable table: PR#, summary, claims, domain, outcome, evals, evaluator, cost, date.
Click any row to expand: timeline, claim list, issues summary.
Hero cards: total PRs, merge rate, median eval rounds, total claims, total cost.

Data sources: prs table, audit_log (eval rounds), review_records.
Owner: Ship
"""

from datetime import datetime

from shared_ui import render_page


EXTRA_CSS = """
  .page-content { max-width: 1600px !important; }
  .filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
  .filters select, .filters input {
    background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
    border-radius: 6px; padding: 6px 10px; font-size: 12px; }
  .filters select:focus, .filters input:focus { border-color: #58a6ff; outline: none; }
  .pr-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
  .pr-table th:nth-child(1) { width: 50px; }    /* PR# */
  .pr-table th:nth-child(2) { width: 30%; }     /* Summary */
  .pr-table th:nth-child(3) { width: 50px; }    /* Claims */
  .pr-table th:nth-child(4) { width: 12%; }     /* Domain */
  .pr-table th:nth-child(5) { width: 10%; }     /* Outcome */
  .pr-table th:nth-child(6) { width: 50px; }    /* Evals */
  .pr-table th:nth-child(7) { width: 16%; }     /* Evaluator */
  .pr-table th:nth-child(8) { width: 70px; }    /* Cost */
  .pr-table th:nth-child(9) { width: 90px; }    /* Date */
  .pr-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 8px 6px; }
  .pr-table td:nth-child(2) { white-space: normal; overflow: visible; line-height: 1.4; }
  .pr-table th { cursor: pointer; user-select: none; position: relative; padding: 8px 18px 8px 6px; }
  .pr-table th:hover { color: #58a6ff; }
  .pr-table th .sort-arrow { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); font-size: 10px; opacity: 0.5; }
  .pr-table th.sorted .sort-arrow { opacity: 1; color: #58a6ff; }
  .pr-table tr { cursor: pointer; transition: background 0.1s; }
  .pr-table tbody tr:hover { background: #161b22; }
  .pr-table .outcome-merged { color: #3fb950; }
  .pr-table .outcome-closed { color: #f85149; }
  .pr-table .outcome-open { color: #d29922; }
  .pr-table .tier-deep { color: #bc8cff; font-weight: 600; }
  .pr-table .tier-standard { color: #58a6ff; }
  .pr-table .tier-light { color: #8b949e; }
  .pr-table .pr-link { color: #58a6ff; text-decoration: none; }
  .pr-table .pr-link:hover { text-decoration: underline; }
  .pr-table td .summary-text { font-size: 12px; color: #c9d1d9; }
  .pr-table td .review-snippet { font-size: 11px; color: #f85149; margin-top: 2px; opacity: 0.8; }
  .pr-table td .model-tag { font-size: 9px; color: #6e7681; background: #21262d; border-radius: 3px; padding: 1px 4px; display: inline-block; margin: 1px 0; }
  .pr-table td .expand-chevron { display: inline-block; width: 12px; color: #484f58; font-size: 10px; transition: transform 0.2s; }
  .pr-table tr.expanded .expand-chevron { transform: rotate(90deg); color: #58a6ff; }
  .pr-table td .cost-val { font-size: 12px; color: #8b949e; }
  .pr-table td .claims-count { font-size: 13px; color: #c9d1d9; text-align: center; }
  .pr-table td .evals-count { font-size: 13px; text-align: center; }
  .trace-panel { background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
    padding: 16px; margin: 4px 0 8px 0; font-size: 12px; display: none; }
  .trace-panel.open { display: block; }
  .trace-panel .section-title { color: #58a6ff; font-size: 12px; font-weight: 600; margin: 12px 0 6px; }
  .trace-panel .section-title:first-child { margin-top: 0; }
  .trace-panel .claim-list { list-style: none; padding: 0; margin: 0; }
  .trace-panel .claim-list li { padding: 4px 0; border-bottom: 1px solid #21262d; color: #c9d1d9; font-size: 12px; }
  .trace-panel .claim-list li:last-child { border-bottom: none; }
  .trace-panel .issues-box { background: #1c1017; border: 1px solid #f8514930; border-radius: 6px;
    padding: 8px 12px; margin: 4px 0; font-size: 12px; color: #f85149; }
  .trace-timeline { list-style: none; padding: 0; }
  .trace-timeline li { padding: 4px 0; border-left: 2px solid #30363d; padding-left: 12px; margin-left: 8px; }
  .trace-timeline li .ts { color: #484f58; font-size: 11px; }
  .trace-timeline li .ev { font-weight: 600; }
  .trace-timeline li.ev-approved .ev { color: #3fb950; }
  .trace-timeline li.ev-rejected .ev { color: #f85149; }
  .trace-timeline li.ev-changes .ev { color: #d29922; }
  .review-text { background: #161b22; padding: 8px 12px; border-radius: 4px;
    margin: 4px 0; white-space: pre-wrap; font-size: 11px; color: #8b949e; max-height: 200px; overflow-y: auto; }
  .eval-chain { background: #161b22; border-radius: 6px; padding: 8px 12px; margin: 4px 0 8px;
    font-size: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
  .eval-chain .step { display: flex; align-items: center; gap: 4px; }
  .eval-chain .step-label { color: #8b949e; font-size: 11px; }
  .eval-chain .step-model { color: #c9d1d9; font-size: 11px; font-weight: 600; }
  .eval-chain .arrow { color: #484f58; }
  .pagination { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 16px; }
  .pagination button { background: #161b22; color: #c9d1d9; border: 1px solid #30363d;
    border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 12px; }
  .pagination button:hover { border-color: #58a6ff; }
  .pagination button:disabled { opacity: 0.4; cursor: default; }
  .pagination .page-info { color: #8b949e; font-size: 12px; }
"""


def render_prs_page(now: datetime) -> str:
    """Render the PR lifecycle page. All data loaded client-side via /api/pr-lifecycle."""

    body = """
    <!-- Hero cards (populated by JS) -->
    <div class="grid" id="hero-cards">
      <div class="card"><div class="label">Total PRs</div><div class="value blue" id="kpi-total">--</div><div class="detail" id="kpi-total-detail"></div></div>
      <div class="card"><div class="label">Merge Rate</div><div class="value green" id="kpi-merge-rate">--</div><div class="detail" id="kpi-merge-detail"></div></div>
      <div class="card"><div class="label">Median Eval Rounds</div><div class="value" id="kpi-rounds">--</div><div class="detail" id="kpi-rounds-detail"></div></div>
      <div class="card"><div class="label">Total Claims</div><div class="value blue" id="kpi-claims">--</div><div class="detail" id="kpi-claims-detail"></div></div>
      <div class="card"><div class="label">Est. Cost</div><div class="value" id="kpi-cost">--</div><div class="detail" id="kpi-cost-detail"></div></div>
    </div>

    <!-- Filters -->
    <div class="filters">
      <select id="filter-domain"><option value="">All Domains</option></select>
      <select id="filter-outcome">
        <option value="">All Outcomes</option>
        <option value="merged">Merged</option>
        <option value="closed">Rejected</option>
        <option value="open">Open</option>
      </select>
      <select id="filter-tier">
        <option value="">All Tiers</option>
        <option value="DEEP">Deep</option>
        <option value="STANDARD">Standard</option>
        <option value="LIGHT">Light</option>
      </select>
      <select id="filter-days">
        <option value="7">Last 7 days</option>
        <option value="30" selected>Last 30 days</option>
        <option value="90">Last 90 days</option>
        <option value="0">All time</option>
      </select>
    </div>

    <!-- PR table -->
    <div class="card" style="padding: 0; overflow: hidden;">
      <table class="pr-table">
        <thead>
          <tr>
            <th data-col="number">PR# <span class="sort-arrow">&#9650;</span></th>
            <th data-col="summary">Summary <span class="sort-arrow">&#9650;</span></th>
            <th data-col="claims_count">Claims <span class="sort-arrow">&#9650;</span></th>
            <th data-col="domain">Domain <span class="sort-arrow">&#9650;</span></th>
            <th data-col="status">Outcome <span class="sort-arrow">&#9650;</span></th>
            <th data-col="eval_rounds">Evals <span class="sort-arrow">&#9650;</span></th>
            <th data-col="evaluator">Evaluator <span class="sort-arrow">&#9650;</span></th>
            <th data-col="est_cost">Cost <span class="sort-arrow">&#9650;</span></th>
            <th data-col="created_at">Date <span class="sort-arrow">&#9650;</span></th>
          </tr>
        </thead>
        <tbody id="pr-tbody"></tbody>
      </table>
    </div>

    <!-- Pagination -->
    <div class="pagination">
      <button id="pg-prev" disabled>&laquo; Prev</button>
      <span class="page-info" id="pg-info">--</span>
      <button id="pg-next" disabled>Next &raquo;</button>
    </div>
    """

    # Use single-quoted JS strings throughout to avoid Python/HTML escaping issues
    scripts = """<script>
    const PAGE_SIZE = 50;
    const FORGEJO = 'https://git.livingip.xyz/teleo/teleo-codex/pulls/';
    let allData = [];
    let filtered = [];
    let sortCol = 'number';
    let sortAsc = false;
    let page = 0;
    let expandedPr = null;

    function loadData() {
      var days = document.getElementById('filter-days').value;
      var url = '/api/pr-lifecycle' + (days !== '0' ? '?days=' + days : '?days=9999');
      fetch(url).then(function(r) { return r.json(); }).then(function(data) {
        allData = data.prs || [];
        populateFilters(allData);
        updateKPIs(data);
        applyFilters();
      }).catch(function() {
        document.getElementById('pr-tbody').innerHTML =
          '<tr><td colspan="9" style="text-align:center;color:#f85149;">Failed to load data</td></tr>';
      });
    }

    function populateFilters(prs) {
      var domains = [], seenD = {};
      prs.forEach(function(p) {
        if (p.domain && !seenD[p.domain]) { seenD[p.domain] = 1; domains.push(p.domain); }
      });
      domains.sort();
      var domSel = document.getElementById('filter-domain');
      var curDom = domSel.value;
      domSel.innerHTML = '<option value="">All Domains</option>' +
        domains.map(function(d) { return '<option value="' + esc(d) + '">' + esc(d) + '</option>'; }).join('');
      domSel.value = curDom;
    }

    function updateKPIs(data) {
      document.getElementById('kpi-total').textContent = fmtNum(data.total);
      document.getElementById('kpi-total-detail').textContent =
        fmtNum(data.merged) + ' merged, ' + fmtNum(data.closed) + ' rejected';

      var rate = data.total > 0 ? data.merged / (data.merged + data.closed) : 0;
      document.getElementById('kpi-merge-rate').textContent = fmtPct(rate);
      document.getElementById('kpi-merge-detail').textContent = fmtNum(data.open) + ' open';

      document.getElementById('kpi-rounds').textContent =
        data.median_rounds != null ? data.median_rounds.toFixed(1) : '--';
      document.getElementById('kpi-rounds-detail').textContent =
        data.max_rounds != null ? 'max: ' + data.max_rounds : '';

      var totalClaims = 0, mergedClaims = 0;
      var totalCost = 0;
      var actualCount = 0, estCount = 0;
      (data.prs || []).forEach(function(p) {
        totalClaims += (p.claims_count || 1);
        if (p.status === 'merged') mergedClaims += (p.claims_count || 1);
        totalCost += (p.cost || 0);
        if (p.cost_is_actual) actualCount++; else estCount++;
      });
      document.getElementById('kpi-claims').textContent = fmtNum(totalClaims);
      document.getElementById('kpi-claims-detail').textContent = fmtNum(mergedClaims) + ' merged';

      // Show actual DB total if available, otherwise sum from PRs
      var costLabel = '';
      if (data.actual_total_cost > 0) {
        document.getElementById('kpi-cost').textContent = '$' + data.actual_total_cost.toFixed(2);
        costLabel = 'from costs table';
      } else if (actualCount > 0) {
        document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
        costLabel = actualCount + ' actual, ' + estCount + ' est.';
      } else {
        document.getElementById('kpi-cost').textContent = '$' + totalCost.toFixed(2);
        costLabel = 'ALL ESTIMATED';
      }
      var costPerClaim = totalClaims > 0 ? totalCost / totalClaims : 0;
      document.getElementById('kpi-cost-detail').textContent =
        '$' + costPerClaim.toFixed(3) + '/claim \u00b7 ' + costLabel;
    }

    function applyFilters() {
      var dom = document.getElementById('filter-domain').value;
      var out = document.getElementById('filter-outcome').value;
      var tier = document.getElementById('filter-tier').value;

      filtered = allData.filter(function(p) {
        if (dom && p.domain !== dom) return false;
        if (out && p.status !== out) return false;
        if (tier && p.tier !== tier) return false;
        return true;
      });

      sortData();
      page = 0;
      renderTable();
    }

    function sortData() {
      filtered.sort(function(a, b) {
        var va = a[sortCol], vb = b[sortCol];
        if (va == null) va = '';
        if (vb == null) vb = '';
        if (typeof va === 'number' && typeof vb === 'number') {
          return sortAsc ? va - vb : vb - va;
        }
        va = String(va).toLowerCase();
        vb = String(vb).toLowerCase();
        return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
      });
    }

    function truncate(s, n) {
      if (!s) return '';
      return s.length > n ? s.substring(0, n) + '...' : s;
    }

    function shortModel(m) {
      if (!m) return '';
      // Shorten model names for display
      if (m.indexOf('gemini-2.5-flash') !== -1) return 'Gemini Flash';
      if (m.indexOf('claude-sonnet') !== -1 || m.indexOf('sonnet-4') !== -1) return 'Sonnet';
      if (m.indexOf('claude-opus') !== -1 || m.indexOf('opus') !== -1) return 'Opus';
      if (m.indexOf('haiku') !== -1) return 'Haiku';
      if (m.indexOf('gpt-4o') !== -1) return 'GPT-4o';
      // fallback: strip provider prefix
      var parts = m.split('/');
      return parts[parts.length - 1];
    }

    function renderTable() {
      var tbody = document.getElementById('pr-tbody');
      var start = page * PAGE_SIZE;
      var slice = filtered.slice(start, start + PAGE_SIZE);
      var totalPages = Math.ceil(filtered.length / PAGE_SIZE);

      if (slice.length === 0) {
        tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#8b949e;">No PRs match filters</td></tr>';
        return;
      }

      var rows = [];
      slice.forEach(function(p) {
        var outClass = p.status === 'merged' ? 'outcome-merged' :
                       p.status === 'closed' ? 'outcome-closed' : 'outcome-open';
        var tierClass = (p.tier || '').toLowerCase() === 'deep' ? 'tier-deep' :
                        (p.tier || '').toLowerCase() === 'standard' ? 'tier-standard' : 'tier-light';
        var date = p.created_at ? p.created_at.substring(0, 10) : '--';

        // Summary
        var summary = p.summary || '--';
        var reviewSnippet = '';
        if (p.status === 'closed' && p.review_snippet) {
          reviewSnippet = '<div class="review-snippet">' + esc(truncate(p.review_snippet, 120)) + '</div>';
        }

        // Outcome with tier badge
        var outcomeLabel = esc(p.status || '--');
        var tierBadge = p.tier ? ' <span class="' + tierClass + '" style="font-size:10px;">' + esc(p.tier) + '</span>' : '';

        // Evaluator column: domain agent + model
        var evaluator = '';
        if (p.domain_agent) {
          evaluator = '<div style="font-size:12px;color:#c9d1d9;">' + esc(p.domain_agent) + '</div>';
        }
        if (p.domain_model) {
          evaluator += '<div class="model-tag">' + esc(shortModel(p.domain_model)) + '</div>';
        }
        if (p.leo_model) {
          evaluator += '<div class="model-tag">' + esc(shortModel(p.leo_model)) + '</div>';
        }
        if (!evaluator) evaluator = '<span style="color:#484f58;">--</span>';

        // Cost — actual from DB or estimated (flagged)
        var costStr;
        if (p.cost != null && p.cost > 0) {
          if (p.cost_is_actual) {
            costStr = '<span class="cost-val">$' + p.cost.toFixed(3) + '</span>';
          } else {
            costStr = '<span class="cost-val" style="opacity:0.5;" title="Estimated — no actual cost tracked">~$' + p.cost.toFixed(3) + '</span>';
          }
        } else {
          costStr = '<span style="color:#484f58;">--</span>';
        }

        rows.push(
          '<tr data-pr="' + p.number + '">' +
          '<td><span class="expand-chevron">&#9654;</span> ' +
            '<a class="pr-link" href="' + FORGEJO + p.number + '" target="_blank" rel="noopener" onclick="event.stopPropagation();">#' + p.number + '</a></td>' +
          '<td style="white-space:normal;"><span class="summary-text">' + esc(summary) + '</span>' + reviewSnippet + '</td>' +
          '<td style="text-align:center;">' + (p.claims_count || '--') + '</td>' +
          '<td>' + esc(p.domain || '--') + '</td>' +
          '<td class="' + outClass + '">' + outcomeLabel + tierBadge + '</td>' +
          '<td style="text-align:center;">' + (p.eval_rounds || '--') + '</td>' +
          '<td>' + evaluator + '</td>' +
          '<td>' + costStr + '</td>' +
          '<td>' + date + '</td>' +
          '</tr>' +
          '<tr id="trace-' + p.number + '" style="display:none;"><td colspan="9" style="padding:0;">' +
          '<div class="trace-panel" id="panel-' + p.number + '">Loading trace...</div>' +
          '</td></tr>'
        );
      });
      tbody.innerHTML = rows.join('');

      // Pagination
      document.getElementById('pg-info').textContent =
        'Page ' + (totalPages > 0 ? page + 1 : 0) + ' of ' + totalPages +
        ' (' + filtered.length + ' PRs)';
      document.getElementById('pg-prev').disabled = page <= 0;
      document.getElementById('pg-next').disabled = page >= totalPages - 1;

      // Update sort arrows
      document.querySelectorAll('.pr-table th').forEach(function(th) {
        th.classList.toggle('sorted', th.dataset.col === sortCol);
        var arrow = th.querySelector('.sort-arrow');
        if (arrow) arrow.innerHTML = (th.dataset.col === sortCol && sortAsc) ? '&#9650;' : '&#9660;';
      });
    }

    // Sort click
    document.querySelectorAll('.pr-table th').forEach(function(th) {
      th.addEventListener('click', function() {
        var col = th.dataset.col;
        if (col === sortCol) { sortAsc = !sortAsc; }
        else { sortCol = col; sortAsc = col === 'number' ? false : true; }
        sortData();
        renderTable();
      });
    });

    // Row click -> trace expand
    document.getElementById('pr-tbody').addEventListener('click', function(e) {
      if (e.target.closest('a')) return;
      var row = e.target.closest('tr[data-pr]');
      if (!row) return;
      var pr = row.dataset.pr;
      var traceRow = document.getElementById('trace-' + pr);
      var panel = document.getElementById('panel-' + pr);
      if (!traceRow) return;

      if (traceRow.style.display === 'none') {
        if (expandedPr && expandedPr !== pr) {
          var prev = document.getElementById('trace-' + expandedPr);
          if (prev) prev.style.display = 'none';
          var prevRow = document.querySelector('tr[data-pr="' + expandedPr + '"]');
          if (prevRow) prevRow.classList.remove('expanded');
        }
        traceRow.style.display = '';
        panel.classList.add('open');
        row.classList.add('expanded');
        expandedPr = pr;
        loadTrace(pr, panel);
      } else {
        traceRow.style.display = 'none';
        panel.classList.remove('open');
        row.classList.remove('expanded');
        expandedPr = null;
      }
    });

    function loadTrace(pr, panel) {
      // Also find this PR in allData for claim list
      var prData = null;
      allData.forEach(function(p) { if (p.number == pr) prData = p; });

      fetch('/api/trace/' + pr).then(function(r) { return r.json(); }).then(function(data) {
        var html = '';

        // --- Claims contained in this PR ---
        if (prData && prData.claim_titles && prData.claim_titles.length > 0) {
          html += '<div class="section-title">Claims (' + prData.claim_titles.length + ')</div>';
          html += '<ul class="claim-list">';
          prData.claim_titles.forEach(function(t) {
            html += '<li>' + esc(t) + '</li>';
          });
          html += '</ul>';
        }

        // --- Issues summary ---
        var issues = [];
        if (data.timeline) {
          data.timeline.forEach(function(ev) {
            if (ev.detail && ev.detail.issues) {
              var iss = ev.detail.issues;
              if (typeof iss === 'string') { try { iss = JSON.parse(iss); } catch(e) { iss = [iss]; } }
              if (Array.isArray(iss)) {
                iss.forEach(function(i) {
                  var label = String(i).replace(/_/g, ' ');
                  if (issues.indexOf(label) === -1) issues.push(label);
                });
              }
            }
          });
        }
        if (prData && prData.review_snippet) {
          html += '<div class="issues-box">' + esc(prData.review_snippet) + '</div>';
        } else if (issues.length > 0) {
          html += '<div class="issues-box">Issues: ' + issues.map(esc).join(', ') + '</div>';
        }

        // --- Eval chain (who reviewed with what model) ---
        var models = {};
        if (data.timeline) {
          data.timeline.forEach(function(ev) {
            if (ev.detail) {
              if (ev.detail.model) models[ev.stage + '.' + ev.event] = ev.detail.model;
              if (ev.detail.domain_model) models['domain_review'] = ev.detail.domain_model;
              if (ev.detail.leo_model) models['leo_review'] = ev.detail.leo_model;
            }
          });
        }
        if (Object.keys(models).length > 0) {
          html += '<div class="eval-chain">';
          html += '<strong style="color:#58a6ff;">Eval chain:</strong> ';
          var parts = [];
          if (models['triage.haiku_triage'] || models['triage.deterministic_triage'])
            parts.push('<span class="step"><span class="step-label">Triage</span> <span class="step-model">' + shortModel(models['triage.haiku_triage'] || 'deterministic') + '</span></span>');
          if (models['domain_review'])
            parts.push('<span class="step"><span class="step-label">Domain</span> <span class="step-model">' + shortModel(models['domain_review']) + '</span></span>');
          if (models['leo_review'])
            parts.push('<span class="step"><span class="step-label">Leo</span> <span class="step-model">' + shortModel(models['leo_review']) + '</span></span>');
          html += parts.length > 0 ? parts.join(' <span class="arrow">&#8594;</span> ') : '<span style="color:#484f58;">No model data</span>';
          html += '</div>';
        }

        // --- Timeline ---
        if (data.timeline && data.timeline.length > 0) {
          html += '<div class="section-title">Timeline</div>';
          html += '<ul class="trace-timeline">';
          data.timeline.forEach(function(ev) {
            var cls = ev.event === 'approved' ? 'ev-approved' :
                      (ev.event === 'domain_rejected' || ev.event === 'tier05_rejected') ? 'ev-rejected' :
                      ev.event === 'changes_requested' ? 'ev-changes' : '';
            var ts = ev.timestamp ? ev.timestamp.substring(0, 19).replace('T', ' ') : '';
            var detail = '';
            if (ev.detail) {
              if (ev.detail.tier) detail += ' tier=' + ev.detail.tier;
              if (ev.detail.reason) detail += ' &#8212; ' + esc(ev.detail.reason);
              if (ev.detail.model) detail += ' [' + esc(shortModel(ev.detail.model)) + ']';
              if (ev.detail.review_text) {
                detail += '<div class="review-text">' + esc(ev.detail.review_text).substring(0, 2000) + '</div>';
              }
              if (ev.detail.domain_review_text) {
                detail += '<div class="review-text"><strong>Domain review:</strong><br>' + esc(ev.detail.domain_review_text).substring(0, 2000) + '</div>';
              }
              if (ev.detail.leo_review_text) {
                detail += '<div class="review-text"><strong>Leo review:</strong><br>' + esc(ev.detail.leo_review_text).substring(0, 2000) + '</div>';
              }
            }
            html += '<li class="' + cls + '">' +
              '<span class="ts">' + ts + '</span> ' +
              '<span class="ev">' + esc(ev.stage + '.' + ev.event) + '</span>' +
              detail + '</li>';
          });
          html += '</ul>';
        } else {
          html += '<div style="color:#484f58;font-size:12px;margin-top:8px;">No timeline events</div>';
        }

        // --- Reviews ---
        if (data.reviews && data.reviews.length > 0) {
          html += '<div class="section-title">Reviews</div>';
          data.reviews.forEach(function(r) {
            var cls = r.outcome === 'approved' ? 'badge-green' :
                      r.outcome === 'rejected' ? 'badge-red' : 'badge-yellow';
            html += '<div style="margin:4px 0;">' +
              '<span class="badge ' + cls + '">' + esc(r.outcome) + '</span> ' +
              '<span style="color:#8b949e;font-size:11px;">' + esc(r.reviewer || '') + ' ' +
              (r.model ? '[' + esc(shortModel(r.model)) + ']' : '') + ' ' +
              (r.reviewed_at || '').substring(0, 19) + '</span>';
            if (r.rejection_reason) {
              html += ' <code>' + esc(r.rejection_reason) + '</code>';
            }
            if (r.notes) {
              html += '<div class="review-text">' + esc(r.notes) + '</div>';
            }
            html += '</div>';
          });
        }

        panel.innerHTML = html || '<div style="color:#484f58;font-size:12px;">No trace data</div>';
      }).catch(function() {
        panel.innerHTML = '<div style="color:#f85149;font-size:12px;">Failed to load trace</div>';
      });
    }

    // Filter listeners
    ['filter-domain', 'filter-outcome', 'filter-tier'].forEach(function(id) {
      document.getElementById(id).addEventListener('change', applyFilters);
    });
    document.getElementById('filter-days').addEventListener('change', loadData);

    // Pagination
    document.getElementById('pg-prev').addEventListener('click', function() { page--; renderTable(); });
    document.getElementById('pg-next').addEventListener('click', function() { page++; renderTable(); });

    // Init
    loadData();
    </script>"""

    return render_page(
        title="PR Lifecycle",
        subtitle="Every PR through the pipeline — triage to merge",
        active_path="/prs",
        body_html=body,
        scripts=scripts,
        extra_css=EXTRA_CSS,
        timestamp=now.strftime("%Y-%m-%d %H:%M UTC"),
    )
