proposal-list.ts
typescript
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42
refactor: rename merge strategy aliases to canonical names
Sonnet 4.6
minor
⚠ breaking
11 days ago
| 1 | /** |
| 2 | * proposal-list.ts — Proposal list mission-control dashboard. |
| 3 | * |
| 4 | * Responsibilities: |
| 5 | * 1. Sync filter-bar active state after HTMX swaps (tab, sort, author filter). |
| 6 | * 2. Restart row entrance animation after each HTMX row-set swap. |
| 7 | * 3. Row expansion — toggle detail panel visibility on row click (HTMX lazy-loads |
| 8 | * the panel content on first expand, then just shows/hides on subsequent clicks). |
| 9 | * 4. Readiness poll — refresh the readiness widget every 30 s while settling |
| 10 | * proposals are visible (HTMX handles the domain-heat polling; we handle the |
| 11 | * merge-readiness chips via a native interval so the two pulses stay independent). |
| 12 | */ |
| 13 | |
| 14 | type PageData = Record<string, unknown>; |
| 15 | |
| 16 | const READINESS_POLL_MS = 30_000; |
| 17 | |
| 18 | let _pageData: PageData = {}; |
| 19 | |
| 20 | export function initProposalList(data: PageData): void { |
| 21 | _pageData = data; |
| 22 | syncFilterBar(); |
| 23 | hookHtmxSwap(); |
| 24 | hookRowExpansion(); |
| 25 | maybeStartReadinessPoll(); |
| 26 | } |
| 27 | |
| 28 | // ── Filter-bar sync ─────────────────────────────────────────────────────────── |
| 29 | |
| 30 | /** Re-derive active state from the current URL and mark matching controls. */ |
| 31 | function syncFilterBar(): void { |
| 32 | const params = new URLSearchParams(window.location.search); |
| 33 | const state = params.get('state') ?? 'open'; |
| 34 | const sort = params.get('sort') ?? 'newest'; |
| 35 | const authorType = params.get('author_type') ?? 'all'; |
| 36 | |
| 37 | document.querySelectorAll<HTMLAnchorElement>('.prl-tab').forEach((tab) => { |
| 38 | const url = new URL(tab.href, location.origin); |
| 39 | tab.classList.toggle('prl-tab--active', (url.searchParams.get('state') ?? 'open') === state); |
| 40 | }); |
| 41 | |
| 42 | document.querySelectorAll<HTMLAnchorElement>('.prl-sort-opt').forEach((opt) => { |
| 43 | const url = new URL(opt.href, location.origin); |
| 44 | opt.classList.toggle('prl-sort-opt--active', (url.searchParams.get('sort') ?? 'newest') === sort); |
| 45 | }); |
| 46 | |
| 47 | document.querySelectorAll<HTMLAnchorElement>('.prl-filter-opt').forEach((opt) => { |
| 48 | const url = new URL(opt.href, location.origin); |
| 49 | opt.classList.toggle('prl-filter-opt--active', (url.searchParams.get('author_type') ?? 'all') === authorType); |
| 50 | }); |
| 51 | } |
| 52 | |
| 53 | // ── HTMX swap hook ──────────────────────────────────────────────────────────── |
| 54 | |
| 55 | function hookHtmxSwap(): void { |
| 56 | document.body.addEventListener('htmx:afterSwap', (e: Event) => { |
| 57 | const target = (e as CustomEvent).detail?.target as Element | undefined; |
| 58 | if (!target) return; |
| 59 | |
| 60 | const rowsContainer = target.id === 'proposal-rows' |
| 61 | ? target |
| 62 | : target.closest('#proposal-rows'); |
| 63 | |
| 64 | if (rowsContainer) { |
| 65 | syncFilterBar(); |
| 66 | restaggerRows(rowsContainer); |
| 67 | // Re-wire expansion for freshly injected rows |
| 68 | wireExpansion(rowsContainer); |
| 69 | maybeStartReadinessPoll(); |
| 70 | } |
| 71 | }); |
| 72 | } |
| 73 | |
| 74 | function restaggerRows(container: Element): void { |
| 75 | container.querySelectorAll<HTMLElement>('.prl-row').forEach((row, i) => { |
| 76 | row.style.animationDelay = `${i < 8 ? i * 28 : 0}ms`; |
| 77 | row.style.animation = 'none'; |
| 78 | void row.offsetHeight; // force reflow |
| 79 | row.style.animation = ''; |
| 80 | }); |
| 81 | } |
| 82 | |
| 83 | // ── Row expansion ───────────────────────────────────────────────────────────── |
| 84 | |
| 85 | function hookRowExpansion(): void { |
| 86 | const container = document.getElementById('proposal-rows'); |
| 87 | if (container) wireExpansion(container); |
| 88 | } |
| 89 | |
| 90 | function wireExpansion(container: Element): void { |
| 91 | container.querySelectorAll<HTMLElement>('.prl-row').forEach((row) => { |
| 92 | // Skip rows that already have a listener (data attribute guard) |
| 93 | if (row.dataset.expansionWired) return; |
| 94 | row.dataset.expansionWired = '1'; |
| 95 | |
| 96 | row.addEventListener('click', (e) => { |
| 97 | // Let anchor child clicks (SHA links, dep links) propagate normally |
| 98 | if ((e.target as Element).closest('a[href]:not(.prl-row)')) return; |
| 99 | e.preventDefault(); |
| 100 | |
| 101 | const detailWrap = row.querySelector<HTMLElement>('.prl-row-detail-wrap'); |
| 102 | if (!detailWrap) return; |
| 103 | |
| 104 | const expanded = row.classList.toggle('prl-row--expanded'); |
| 105 | detailWrap.hidden = !expanded; |
| 106 | |
| 107 | // If this is the first expand, HTMX will fire the lazy-load automatically |
| 108 | // (hx-trigger="click[...] once"). On subsequent expands we just show/hide. |
| 109 | }); |
| 110 | }); |
| 111 | } |
| 112 | |
| 113 | // ── Readiness poll ──────────────────────────────────────────────────────────── |
| 114 | |
| 115 | let readinessPollTimer: ReturnType<typeof setInterval> | null = null; |
| 116 | |
| 117 | function maybeStartReadinessPoll(): void { |
| 118 | const hasSettling = !!document.querySelector('.prl-settling'); |
| 119 | |
| 120 | if (hasSettling && readinessPollTimer === null) { |
| 121 | readinessPollTimer = setInterval(refreshReadiness, READINESS_POLL_MS); |
| 122 | } else if (!hasSettling && readinessPollTimer !== null) { |
| 123 | clearInterval(readinessPollTimer); |
| 124 | readinessPollTimer = null; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | function refreshReadiness(): void { |
| 129 | // Re-check: stop poll if no settling rows remain (they may have merged) |
| 130 | if (!document.querySelector('.prl-settling')) { |
| 131 | if (readinessPollTimer !== null) { |
| 132 | clearInterval(readinessPollTimer); |
| 133 | readinessPollTimer = null; |
| 134 | } |
| 135 | return; |
| 136 | } |
| 137 | |
| 138 | const readiness = document.querySelector<HTMLElement>('.prl-readiness'); |
| 139 | if (!readiness) return; |
| 140 | |
| 141 | const repoId = _pageData.repoId as string | undefined; |
| 142 | if (!repoId) return; |
| 143 | |
| 144 | fetch(`/api/repos/${repoId}/proposals/readiness`) |
| 145 | .then((r) => (r.ok ? r.json() : null)) |
| 146 | .then((data) => { |
| 147 | if (!data) return; |
| 148 | updateReadinessChips(readiness, data as ReadinessPayload); |
| 149 | }) |
| 150 | .catch(() => { /* silent — UI degrades gracefully */ }); |
| 151 | } |
| 152 | |
| 153 | interface ReadinessPayload { |
| 154 | ready: string[]; |
| 155 | blocked: string[]; |
| 156 | settling: string[]; |
| 157 | needs_review: string[]; |
| 158 | } |
| 159 | |
| 160 | function updateReadinessChips(container: HTMLElement, data: ReadinessPayload): void { |
| 161 | setChipCount(container, '.prl-ready-chip--ready', data.ready.length, '✅', 'ready'); |
| 162 | setChipCount(container, '.prl-ready-chip--blocked', data.blocked.length, '⛔', 'blocked'); |
| 163 | setChipCount(container, '.prl-ready-chip--settling', data.settling.length, '⏳', 'settling'); |
| 164 | setChipCount(container, '.prl-ready-chip--review', data.needs_review.length, '👁', 'needs review'); |
| 165 | |
| 166 | // Re-evaluate poll necessity after chip update |
| 167 | maybeStartReadinessPoll(); |
| 168 | } |
| 169 | |
| 170 | function setChipCount( |
| 171 | container: HTMLElement, |
| 172 | selector: string, |
| 173 | count: number, |
| 174 | icon: string, |
| 175 | label: string, |
| 176 | ): void { |
| 177 | const chip = container.querySelector<HTMLElement>(selector); |
| 178 | if (count > 0) { |
| 179 | if (chip) { |
| 180 | chip.textContent = `${icon} ${count} ${label}`; |
| 181 | chip.hidden = false; |
| 182 | } |
| 183 | } else if (chip) { |
| 184 | chip.hidden = true; |
| 185 | } |
| 186 | } |
File History
1 commit
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42
refactor: rename merge strategy aliases to canonical names
Sonnet 4.6
minor
⚠
11 days ago