gabriel / musehub public
proposal-list.ts typescript
186 lines 6.9 KB
Raw
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