PageSourceSearch

https://smrtbld.ai/js/pages/portfolio.js

js smrtbld.ai collected 2026-10-05 15:07:16 UTC 8,456 bytes, 222 lines download raw bytes

1/**
2 * portfolio.js — CHOP portfolio dashboard.
3 *
4 * Loads a single portfolio snapshot, renders the project table, and applies
5 * client-side health/phase/search filters with sorting. Includes the CHOP
6 * Task/Gate Log exports (Excel + PDF).
7 */
8
9// ========================================
10// IMPORTS
11// ========================================
12
13import { portfolioApi } from '../api/portfolio.js';
14import { render, loadTemplates } from '../utils/templates.js';
15import { runWithBusyButton } from '../utils/dom.js';
16import { attachDropdownMenu } from '../utils/dropdown-menu.js';
17import { downloadBlob } from '../utils/download.js';
18import { debounce } from '../utils/debounce.js';
19
20// ========================================
21// MODULE STATE
22// ========================================
23
24let abortController = null;
25let portfolioData = null;
26let portfolioTemplatesLoaded = false;
27
28// Current filter/sort state
29let activeHealth = 'all';
30let activePhase = 'all';
31let activeSort = 'health';
32let searchTerm = '';
33
34// ========================================
35// PUBLIC API
36// ========================================
37
38/** @param {AbortSignal|null} signal - the route transition's signal; a later route aborts it (CA-556). */
39export async function initPortfolioPage(signal = null) {
40    abortController = new AbortController();
41    const pageSignal = abortController.signal;
42    if (!portfolioTemplatesLoaded) {
43        await loadTemplates('components/project-sub-templates');
44        portfolioTemplatesLoaded = true;
45    }
46    if (signal?.aborted || pageSignal.aborted) return;
47
48    // Show skeleton loading state
49    const appContent = document.getElementById('app-content');
50    if (appContent) {
51        appContent.innerHTML = `
52            <div class="space-y-6 p-6">
53                ${$.templates('#tpl-skeleton-stats').render({})}
54                ${$.templates('#tpl-skeleton-table').render({})}
55            </div>`;
56    }
57
58    try {
59        const data = await portfolioApi.getPortfolio({ signal: pageSignal });
60        if (pageSignal.aborted) return;
61        portfolioData = data;
62        const html = await render('portfolio-main', portfolioData, '#app-content', undefined, { signal });
63        if (html === null || pageSignal.aborted) return;
64        attachEventHandlers();
65        applyFiltersAndSort();
66    } catch (err) {
67        if (err.name === 'AbortError') return;
68        console.error('[Portfolio] Failed to load portfolio data:', err);
69    }
70}
71
72export function destroyPortfolioPage() {
73    if (abortController) {
74        abortController.abort();
75        abortController = null;
76    }
77    portfolioData = null;
78    activeHealth = 'all';
79    activePhase = 'all';
80    activeSort = 'health';
81    searchTerm = '';
82}
83
84// ========================================
85// EVENT WIRING
86// ========================================
87
88function attachEventHandlers() {
89    const signal = abortController.signal;
90
91    // Health filter pills (delegated)
92    document.getElementById('healthFilter')?.addEventListener('click', (e) => {
93        const pill = e.target.closest('.health-pill');
94        if (!pill) return;
95        activeHealth = pill.dataset.health;
96        document.querySelectorAll('.health-pill').forEach(p => p.classList.remove('active'));
97        pill.classList.add('active');
98        applyFiltersAndSort();
99    }, { signal });
100
101    // Phase filter dropdown
102    document.getElementById('phaseFilter')?.addEventListener('change', (e) => {
103        activePhase = e.target.value;
104        applyFiltersAndSort();
105    }, { signal });
106
107    // Sort dropdown
108    document.getElementById('sortOrder')?.addEventListener('change', (e) => {
109        activeSort = e.target.value;
110        applyFiltersAndSort();
111    }, { signal });
112
113    // Search input (debounced)
114    const debouncedSearch = debounce((value) => {
115        searchTerm = value.trim().toLowerCase();
116        applyFiltersAndSort();
117    }, 200);
118    document.getElementById('portfolioSearch')?.addEventListener('input', (e) => {
119        debouncedSearch(e.target.value);
120    }, { signal });
121
122    // ── CHOP Export dropdown ──
123    const exportDropdown = attachDropdownMenu('btn-chop-export', 'chop-export-menu', { signal });
124    document.addEventListener('click', async (e) => {
125        const btn = e.target.closest('.chop-export-btn');
126        if (!btn) return;
127        exportDropdown.close();
128        const report = btn.dataset.report;
129        const format = btn.dataset.format;
130        await runWithBusyButton(btn, 'Generating...', async () => {
131            let result;
132            if (report === 'task') {
133                result = format === 'pdf' ? await portfolioApi.downloadTaskLogPdf() : await portfolioApi.downloadTaskLogExcel();
134            } else {
135                result = format === 'pdf' ? await portfolioApi.downloadGateLogPdf() : await portfolioApi.downloadGateLogExcel();
136            }
137            downloadBlob(result.blob, result.fileName, { successMessage: `${format.toUpperCase()} downloaded` });
138        }, { errorTitle: 'Export failed', errorLogPrefix: '[Portfolio] Export failed:', resetOnSuccess: true });
139    }, { signal });
140
141    // Project row clicks → navigate to drill-down
142    document.getElementById('projectRows')?.addEventListener('click', (e) => {
143        const row = e.target.closest('.project-row');
144        if (!row) return;
145        const projectId = row.dataset.projectId;
146        if (projectId) {
147            page('/portfolio/' + projectId);
148        }
149    }, { signal });
150}
151
152// ========================================
153// FILTER & SORT
154// ========================================
155
156function applyFiltersAndSort() {
157    const container = document.getElementById('projectRows');
158    if (!container) return;
159
160    const rows = Array.from(container.querySelectorAll('.project-row'));
161
162    // Filter
163    let visibleCount = 0;
164    rows.forEach(row => {
165        const matchesHealth = activeHealth === 'all' || row.dataset.health === activeHealth;
166        const matchesPhase = activePhase === 'all' || row.dataset.phase === activePhase;
167        const matchesSearch = !searchTerm || row.dataset.name.toLowerCase().includes(searchTerm);
168        const visible = matchesHealth && matchesPhase && matchesSearch;
169
170        row.classList.toggle('hidden', !visible);
171        if (visible) visibleCount++;
172    });
173
174    // Sort visible rows
175    // Default: gate-blocked first, then red, amber, green. Within tier: by deadline.
176    const healthOrder = { red: 0, amber: 1, green: 2 };
177    const sortedRows = rows.filter(r => !r.classList.contains('hidden'));
178
179    sortedRows.sort((a, b) => {
180        switch (activeSort) {
181            case 'name':
182                return a.dataset.name.localeCompare(b.dataset.name);
183            case 'velocity':
184                return parseFloat(b.dataset.velocity) - parseFloat(a.dataset.velocity);
185            case 'compliance':
186                return parseInt(b.dataset.compliance) - parseInt(a.dataset.compliance);
187            case 'deadline':
188                return parseInt(a.dataset.deadlineSort) - parseInt(b.dataset.deadlineSort);
189            case 'health':
190            default: {
191                // Gate-blocked projects float to top
192                const aBlocked = a.dataset.gateBlocked === 'true' ? -1 : 0;
193                const bBlocked = b.dataset.gateBlocked === 'true' ? -1 : 0;
194                if (aBlocked !== bBlocked) return aBlocked - bBlocked;
195                // Then by health severity
196                const aHealth = healthOrder[a.dataset.health] ?? 99;
197                const bHealth = healthOrder[b.dataset.health] ?? 99;
198                if (aHealth !== bHealth) return aHealth - bHealth;
199                // Then by deadline within same health tier
200                return parseInt(a.dataset.deadlineSort) - parseInt(b.dataset.deadlineSort);
201            }
202        }
203    });
204
205    // Re-append in sorted order
206    const hiddenRows = rows.filter(r => r.classList.contains('hidden'));
207    sortedRows.forEach(r => container.appendChild(r));
208    hiddenRows.forEach(r => container.appendChild(r));
209
210    // Update visible count badge
211    const countEl = document.getElementById('visibleCount');
212    if (countEl) {
213        const total = rows.length;
214        countEl.textContent = visibleCount < total ? `(${visibleCount} of ${total})` : '';
215    }
216
217    // Show/hide empty state
218    const noResults = document.getElementById('noResults');
219    if (noResults) {
220        noResults.classList.toggle('hidden', visibleCount > 0);
221    }
222}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.