/** * proposal-list.ts — Proposal list mission-control dashboard. * * Responsibilities: * 1. Sync filter-bar active state after HTMX swaps (tab, sort, author filter). * 2. Restart row entrance animation after each HTMX row-set swap. * 3. Row expansion — toggle detail panel visibility on row click (HTMX lazy-loads * the panel content on first expand, then just shows/hides on subsequent clicks). * 4. Readiness poll — refresh the readiness widget every 30 s while settling * proposals are visible (HTMX handles the domain-heat polling; we handle the * merge-readiness chips via a native interval so the two pulses stay independent). */ type PageData = Record; const READINESS_POLL_MS = 30_000; let _pageData: PageData = {}; export function initProposalList(data: PageData): void { _pageData = data; syncFilterBar(); hookHtmxSwap(); hookRowExpansion(); maybeStartReadinessPoll(); } // ── Filter-bar sync ─────────────────────────────────────────────────────────── /** Re-derive active state from the current URL and mark matching controls. */ function syncFilterBar(): void { const params = new URLSearchParams(window.location.search); const state = params.get('state') ?? 'open'; const sort = params.get('sort') ?? 'newest'; const authorType = params.get('author_type') ?? 'all'; document.querySelectorAll('.prl-tab').forEach((tab) => { const url = new URL(tab.href, location.origin); tab.classList.toggle('prl-tab--active', (url.searchParams.get('state') ?? 'open') === state); }); document.querySelectorAll('.prl-sort-opt').forEach((opt) => { const url = new URL(opt.href, location.origin); opt.classList.toggle('prl-sort-opt--active', (url.searchParams.get('sort') ?? 'newest') === sort); }); document.querySelectorAll('.prl-filter-opt').forEach((opt) => { const url = new URL(opt.href, location.origin); opt.classList.toggle('prl-filter-opt--active', (url.searchParams.get('author_type') ?? 'all') === authorType); }); } // ── HTMX swap hook ──────────────────────────────────────────────────────────── function hookHtmxSwap(): void { document.body.addEventListener('htmx:afterSwap', (e: Event) => { const target = (e as CustomEvent).detail?.target as Element | undefined; if (!target) return; const rowsContainer = target.id === 'proposal-rows' ? target : target.closest('#proposal-rows'); if (rowsContainer) { syncFilterBar(); restaggerRows(rowsContainer); // Re-wire expansion for freshly injected rows wireExpansion(rowsContainer); maybeStartReadinessPoll(); } }); } function restaggerRows(container: Element): void { container.querySelectorAll('.prl-row').forEach((row, i) => { row.style.animationDelay = `${i < 8 ? i * 28 : 0}ms`; row.style.animation = 'none'; void row.offsetHeight; // force reflow row.style.animation = ''; }); } // ── Row expansion ───────────────────────────────────────────────────────────── function hookRowExpansion(): void { const container = document.getElementById('proposal-rows'); if (container) wireExpansion(container); } function wireExpansion(container: Element): void { container.querySelectorAll('.prl-row').forEach((row) => { // Skip rows that already have a listener (data attribute guard) if (row.dataset.expansionWired) return; row.dataset.expansionWired = '1'; row.addEventListener('click', (e) => { // Let anchor child clicks (SHA links, dep links) propagate normally if ((e.target as Element).closest('a[href]:not(.prl-row)')) return; e.preventDefault(); const detailWrap = row.querySelector('.prl-row-detail-wrap'); if (!detailWrap) return; const expanded = row.classList.toggle('prl-row--expanded'); detailWrap.hidden = !expanded; // If this is the first expand, HTMX will fire the lazy-load automatically // (hx-trigger="click[...] once"). On subsequent expands we just show/hide. }); }); } // ── Readiness poll ──────────────────────────────────────────────────────────── let readinessPollTimer: ReturnType | null = null; function maybeStartReadinessPoll(): void { const hasSettling = !!document.querySelector('.prl-settling'); if (hasSettling && readinessPollTimer === null) { readinessPollTimer = setInterval(refreshReadiness, READINESS_POLL_MS); } else if (!hasSettling && readinessPollTimer !== null) { clearInterval(readinessPollTimer); readinessPollTimer = null; } } function refreshReadiness(): void { // Re-check: stop poll if no settling rows remain (they may have merged) if (!document.querySelector('.prl-settling')) { if (readinessPollTimer !== null) { clearInterval(readinessPollTimer); readinessPollTimer = null; } return; } const readiness = document.querySelector('.prl-readiness'); if (!readiness) return; const repoId = _pageData.repoId as string | undefined; if (!repoId) return; fetch(`/api/repos/${repoId}/proposals/readiness`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (!data) return; updateReadinessChips(readiness, data as ReadinessPayload); }) .catch(() => { /* silent — UI degrades gracefully */ }); } interface ReadinessPayload { ready: string[]; blocked: string[]; settling: string[]; needs_review: string[]; } function updateReadinessChips(container: HTMLElement, data: ReadinessPayload): void { setChipCount(container, '.prl-ready-chip--ready', data.ready.length, '✅', 'ready'); setChipCount(container, '.prl-ready-chip--blocked', data.blocked.length, '⛔', 'blocked'); setChipCount(container, '.prl-ready-chip--settling', data.settling.length, '⏳', 'settling'); setChipCount(container, '.prl-ready-chip--review', data.needs_review.length, '👁', 'needs review'); // Re-evaluate poll necessity after chip update maybeStartReadinessPoll(); } function setChipCount( container: HTMLElement, selector: string, count: number, icon: string, label: string, ): void { const chip = container.querySelector(selector); if (count > 0) { if (chip) { chip.textContent = `${icon} ${count} ${label}`; chip.hidden = false; } } else if (chip) { chip.hidden = true; } }