trackops 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -575
  3. package/bin/trackops.js +127 -106
  4. package/lib/cli-format.js +118 -0
  5. package/lib/config.js +352 -326
  6. package/lib/control.js +408 -246
  7. package/lib/env.js +234 -222
  8. package/lib/i18n.js +5 -4
  9. package/lib/init.js +390 -282
  10. package/lib/locale.js +41 -41
  11. package/lib/opera-bootstrap.js +1066 -880
  12. package/lib/opera.js +615 -444
  13. package/lib/preferences.js +74 -74
  14. package/lib/registry.js +214 -214
  15. package/lib/release.js +56 -56
  16. package/lib/runtime-state.js +144 -144
  17. package/lib/skills.js +114 -89
  18. package/lib/workspace.js +259 -248
  19. package/locales/en.json +311 -167
  20. package/locales/es.json +314 -170
  21. package/package.json +61 -58
  22. package/scripts/postinstall-locale.js +21 -21
  23. package/scripts/skills-marketplace-smoke.js +124 -124
  24. package/scripts/smoke-tests.js +563 -517
  25. package/scripts/sync-skill-version.js +21 -21
  26. package/scripts/validate-skill.js +103 -103
  27. package/skills/trackops/SKILL.md +126 -122
  28. package/skills/trackops/agents/openai.yaml +7 -7
  29. package/skills/trackops/locales/en/SKILL.md +126 -122
  30. package/skills/trackops/locales/en/references/activation.md +94 -90
  31. package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
  32. package/skills/trackops/locales/en/references/workflow.md +55 -32
  33. package/skills/trackops/references/activation.md +94 -90
  34. package/skills/trackops/references/troubleshooting.md +73 -67
  35. package/skills/trackops/references/workflow.md +55 -32
  36. package/skills/trackops/skill.json +29 -29
  37. package/templates/hooks/post-checkout +2 -2
  38. package/templates/hooks/post-commit +2 -2
  39. package/templates/hooks/post-merge +2 -2
  40. package/templates/opera/agent.md +28 -27
  41. package/templates/opera/architecture/dependency-graph.md +24 -24
  42. package/templates/opera/architecture/runtime-automation.md +24 -24
  43. package/templates/opera/architecture/runtime-operations.md +34 -34
  44. package/templates/opera/en/agent.md +22 -21
  45. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  46. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  47. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  48. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  49. package/templates/opera/en/reviews/integration-audit.md +18 -18
  50. package/templates/opera/en/router.md +24 -19
  51. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  52. package/templates/opera/references/opera-cycle.md +193 -193
  53. package/templates/opera/registry.md +28 -28
  54. package/templates/opera/reviews/delivery-audit.md +18 -18
  55. package/templates/opera/reviews/integration-audit.md +18 -18
  56. package/templates/opera/router.md +54 -49
  57. package/templates/skills/changelog-updater/SKILL.md +69 -69
  58. package/templates/skills/commiter/SKILL.md +99 -99
  59. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  60. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  61. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  62. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  63. package/templates/skills/opera-skill/SKILL.md +279 -0
  64. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  65. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  66. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  67. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  68. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  69. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  70. package/ui/css/base.css +284 -284
  71. package/ui/css/charts.css +425 -425
  72. package/ui/css/components.css +1107 -1107
  73. package/ui/css/onboarding.css +133 -133
  74. package/ui/css/terminal.css +125 -125
  75. package/ui/css/timeline.css +58 -58
  76. package/ui/css/tokens.css +284 -284
  77. package/ui/favicon.svg +5 -5
  78. package/ui/index.html +99 -99
  79. package/ui/js/charts.js +526 -526
  80. package/ui/js/console-logger.js +172 -172
  81. package/ui/js/filters.js +247 -247
  82. package/ui/js/icons.js +129 -129
  83. package/ui/js/keyboard.js +229 -229
  84. package/ui/js/router.js +142 -142
  85. package/ui/js/theme.js +100 -100
  86. package/ui/js/time-tracker.js +248 -248
  87. package/ui/js/views/dashboard.js +870 -870
  88. package/ui/js/views/flash.js +47 -47
  89. package/ui/js/views/projects.js +745 -745
  90. package/ui/js/views/scrum.js +476 -476
  91. package/ui/js/views/settings.js +331 -331
  92. package/ui/js/views/timeline.js +265 -265
@@ -1,745 +1,745 @@
1
- /**
2
- * projects.js — Vista de gestion de espacios de trabajo registrados
3
- * Cards por proyecto con metricas lazy-loaded, KPIs agregados, charts, tooltips.
4
- */
5
-
6
- import { icon } from '../icons.js';
7
- import * as state from '../state.js';
8
- import * as api from '../api.js';
9
- import * as router from '../router.js';
10
- import { esc, formatDate, formatDurationShort } from '../utils.js';
11
- import { flash } from './flash.js';
12
- import { t } from '../i18n.js';
13
-
14
- /** Cache de metricas por proyecto */
15
- const _metricsCache = new Map();
16
-
17
- /** Map OPERA phase IDs to standard dev labels */
18
- const PHASE_LABELS = {
19
- O: { es: 'Diseño', en: 'Design' },
20
- P: { es: 'QA', en: 'QA' },
21
- E: { es: 'Dev', en: 'Dev' },
22
- R: { es: 'Polish', en: 'Polish' },
23
- A: { es: 'Deploy', en: 'Deploy' },
24
- };
25
-
26
- function _phaseLabel(phaseId) {
27
- const locale = state.get('locale') || 'es';
28
- const map = PHASE_LABELS[phaseId];
29
- return map ? map[locale] || map.en : phaseId || '—';
30
- }
31
-
32
- export async function render() {
33
- const projects = state.get('projects') || [];
34
- const currentId = state.get('currentProjectId');
35
- const unavailableCount = projects.filter(p => !p.available).length;
36
-
37
- return `
38
- <div class="view-enter">
39
- <div class="section-header">
40
- <div class="section-header-left">
41
- <p class="eyebrow">${t('ui.projects.eyebrow', {}, 'Workspaces')}</p>
42
- <h2>${t('ui.projects.title', {}, 'Managed projects')} <span style="font-size:var(--text-sm);font-weight:400;color:var(--text-secondary)">(${projects.length})</span></h2>
43
- </div>
44
- <div class="section-header-right">
45
- ${unavailableCount > 0 ? `
46
- <button class="btn btn-ghost btn-sm" style="color:var(--danger)" type="button" id="projects-purge-unavailable" aria-label="Purge unavailable projects">
47
- ${icon('trash', 16)} Purge unavailable (${unavailableCount})
48
- </button>
49
- ` : ''}
50
- <button class="btn btn-ghost btn-sm" type="button" id="projects-refresh-all" aria-label="${t('ui.projects.refreshAll', {}, 'Refresh all')}">
51
- ${icon('refresh', 16)} ${t('ui.projects.refreshAll', {}, 'Refresh all')}
52
- </button>
53
- <button class="btn btn-primary btn-sm" type="button" id="projects-register-new" aria-label="${t('ui.projects.register', {}, 'Register project')}">
54
- ${icon('plus', 16)} ${t('ui.projects.register', {}, 'Register project')}
55
- </button>
56
- </div>
57
- </div>
58
-
59
- <!-- Portfolio KPIs -->
60
- <div class="portfolio-kpis" id="portfolio-kpis">
61
- ${_renderPortfolioKPIs()}
62
- </div>
63
-
64
- <!-- Attention feed + Charts row -->
65
- <div class="portfolio-analytics" id="portfolio-analytics">
66
- <div class="glass-card portfolio-chart" id="portfolio-status-donut">
67
- <h4 class="chart-title">${t('ui.projects.statusDistribution', {}, 'Status distribution')}</h4>
68
- <div class="portfolio-donut-container" id="portfolio-donut-container">
69
- <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
70
- </div>
71
- </div>
72
- <div class="glass-card portfolio-chart" id="portfolio-velocity-chart">
73
- <h4 class="chart-title">${t('ui.projects.velocity', {}, 'Resolution velocity')}</h4>
74
- <div id="portfolio-velocity-container">
75
- <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
76
- </div>
77
- </div>
78
- <div class="glass-card portfolio-attention" id="portfolio-attention">
79
- <h4 class="chart-title">${t('ui.projects.attention', {}, 'Attention required')}</h4>
80
- <div id="portfolio-attention-feed">
81
- <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
82
- </div>
83
- </div>
84
- </div>
85
-
86
- ${projects.length === 0
87
- ? `<div class="empty-state" style="margin:3rem auto;max-width:440px">
88
- ${icon('folder', 32)}
89
- <h3>${t('ui.projects.noProjects', {}, 'No projects registered')}</h3>
90
- <p style="font-size:var(--text-sm);color:var(--text-secondary)">${t('ui.projects.noProjectsDesc', {}, 'Register a project directory to start tracking.')}</p>
91
- <button class="btn btn-primary" type="button" id="projects-register-empty">${icon('plus', 16)} ${t('ui.projects.register', {}, 'Register project')}</button>
92
- </div>`
93
- : `<div class="projects-grid" id="projects-grid">
94
- ${projects.map(p => _renderProjectCard(p, p.id === currentId)).join('')}
95
- </div>`
96
- }
97
-
98
- <!-- Register modal -->
99
- <div class="modal-overlay is-hidden" id="register-modal">
100
- <div class="glass-card modal" role="dialog" aria-modal="true" aria-labelledby="register-modal-title">
101
- <div class="modal-header">
102
- <h2 class="modal-title" id="register-modal-title">${icon('folder', 18)} ${t('ui.projects.registerTitle', {}, 'Register project')}</h2>
103
- <button class="modal-close" type="button" id="register-modal-close" aria-label="Close">&times;</button>
104
- </div>
105
- <div class="modal-body">
106
- <label class="form-label" for="register-path">${t('ui.projects.pathLabel', {}, 'Project directory path')}</label>
107
- <input class="form-input" type="text" id="register-path" placeholder="/home/user/my-project" style="width:100%" />
108
- <p style="font-size:var(--text-xs);color:var(--text-muted);margin-top:var(--space-2)">${t('ui.projects.pathDesc', {}, 'The directory must contain a project_control.json or be initializable with TrackOps.')}</p>
109
- </div>
110
- <div class="modal-footer">
111
- <button class="btn btn-ghost" type="button" id="register-cancel">${t('ui.projects.cancel', {}, 'Cancel')}</button>
112
- <button class="btn btn-primary" type="button" id="register-confirm">${icon('plus', 14)} ${t('ui.projects.confirm', {}, 'Register')}</button>
113
- </div>
114
- </div>
115
- </div>
116
- </div>
117
- `;
118
- }
119
-
120
- export function bindEvents() {
121
- document.getElementById('projects-purge-unavailable')?.addEventListener('click', _purgeUnavailable);
122
- document.getElementById('projects-refresh-all')?.addEventListener('click', _refreshAll);
123
- document.getElementById('projects-register-new')?.addEventListener('click', _showRegisterModal);
124
- document.getElementById('projects-register-empty')?.addEventListener('click', _showRegisterModal);
125
- document.getElementById('register-modal-close')?.addEventListener('click', _hideRegisterModal);
126
- document.getElementById('register-cancel')?.addEventListener('click', _hideRegisterModal);
127
- document.getElementById('register-confirm')?.addEventListener('click', _confirmRegister);
128
- document.getElementById('register-modal')?.addEventListener('click', (e) => {
129
- if (e.target.id === 'register-modal') _hideRegisterModal();
130
- });
131
- document.getElementById('projects-grid')?.addEventListener('click', _handleCardAction);
132
-
133
- // Tooltip hover delegation
134
- document.getElementById('projects-grid')?.addEventListener('mouseover', _handleTooltipShow);
135
- document.getElementById('projects-grid')?.addEventListener('mouseout', _handleTooltipHide);
136
-
137
- _loadAllMetrics();
138
- }
139
-
140
- // ─────────────────────────────── PORTFOLIO KPIs ──────────────────────────────
141
-
142
- function _renderPortfolioKPIs() {
143
- const allMetrics = Array.from(_metricsCache.values());
144
- const activeProjects = (state.get('projects') || []).filter(p => p.available).length;
145
- const totalBlocked = allMetrics.reduce((s, m) => s + (m.totals?.blocked || 0), 0);
146
- const upcomingDeadlines = _getUpcomingMilestones(7).length;
147
-
148
- return `
149
- <div class="portfolio-kpi-grid">
150
- <div class="glass-card portfolio-kpi">
151
- <span class="portfolio-kpi-icon">${icon('folder', 20)}</span>
152
- <div>
153
- <span class="portfolio-kpi-value">${activeProjects}</span>
154
- <span class="portfolio-kpi-label">${t('ui.projects.kpi.active', {}, 'Active projects')}</span>
155
- </div>
156
- </div>
157
- <div class="glass-card portfolio-kpi ${totalBlocked > 0 ? 'kpi-danger' : ''}">
158
- <span class="portfolio-kpi-icon">${icon('alertCircle', 20)}</span>
159
- <div>
160
- <span class="portfolio-kpi-value" ${totalBlocked > 0 ? 'style="color:var(--danger)"' : ''}>${totalBlocked}</span>
161
- <span class="portfolio-kpi-label">${t('ui.projects.kpi.blocked', {}, 'Blocked tasks')}</span>
162
- </div>
163
- </div>
164
- <div class="glass-card portfolio-kpi ${upcomingDeadlines > 0 ? 'kpi-warning' : ''}">
165
- <span class="portfolio-kpi-icon">${icon('calendar', 20)}</span>
166
- <div>
167
- <span class="portfolio-kpi-value" ${upcomingDeadlines > 0 ? 'style="color:var(--warning)"' : ''}>${upcomingDeadlines}</span>
168
- <span class="portfolio-kpi-label">${t('ui.projects.kpi.deadlines', {}, 'Deadlines this week')}</span>
169
- </div>
170
- </div>
171
- </div>
172
- `;
173
- }
174
-
175
- function _getUpcomingMilestones(days) {
176
- const now = Date.now();
177
- const limit = now + days * 86400000;
178
- const results = [];
179
- for (const [id, m] of _metricsCache) {
180
- if (!m.milestones) continue;
181
- for (const ms of m.milestones) {
182
- const d = new Date(ms.date).getTime();
183
- if (d >= now && d <= limit) {
184
- const project = (state.get('projects') || []).find(p => p.id === id);
185
- results.push({ ...ms, projectName: project?.name || id });
186
- }
187
- }
188
- }
189
- return results;
190
- }
191
-
192
- // ─────────────────────────────── PORTFOLIO ANALYTICS ─────────────────────────
193
-
194
- function _updatePortfolioAnalytics() {
195
- // Update KPIs
196
- const kpisEl = document.getElementById('portfolio-kpis');
197
- if (kpisEl) kpisEl.innerHTML = _renderPortfolioKPIs();
198
-
199
- // Status donut
200
- _renderStatusDonut();
201
-
202
- // Velocity
203
- _renderVelocityChart();
204
-
205
- // Attention feed
206
- _renderAttentionFeed();
207
- }
208
-
209
- function _renderStatusDonut() {
210
- const container = document.getElementById('portfolio-donut-container');
211
- if (!container) return;
212
-
213
- const allMetrics = Array.from(_metricsCache.values());
214
- const phases = {};
215
- for (const m of allMetrics) {
216
- const phaseId = m.activePhase?.id || '?';
217
- const label = _phaseLabel(phaseId);
218
- phases[label] = (phases[label] || 0) + 1;
219
- }
220
-
221
- const entries = Object.entries(phases);
222
- if (entries.length === 0) {
223
- container.innerHTML = `<p class="text-muted" style="font-size:var(--text-xs)">No data</p>`;
224
- return;
225
- }
226
-
227
- const total = entries.reduce((s, [, v]) => s + v, 0);
228
- const colors = ['var(--accent)', 'var(--success)', 'var(--warning)', 'var(--danger)', 'var(--info)'];
229
- const r = 40, cx = 60, cy = 60, sw = 12;
230
- const circ = 2 * Math.PI * r;
231
- let offset = 0;
232
-
233
- const arcs = entries.map(([label, count], i) => {
234
- const pct = count / total;
235
- const dashLen = pct * circ;
236
- const arc = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
237
- stroke="${colors[i % colors.length]}" stroke-width="${sw}"
238
- stroke-dasharray="${dashLen} ${circ - dashLen}"
239
- stroke-dashoffset="${-offset}" stroke-linecap="round"
240
- transform="rotate(-90 ${cx} ${cy})" />`;
241
- offset += dashLen;
242
- return arc;
243
- });
244
-
245
- const legend = entries.map(([label, count], i) =>
246
- `<div class="donut-legend-item">
247
- <span class="donut-legend-dot" style="background:${colors[i % colors.length]}"></span>
248
- <span>${label}</span>
249
- <span class="text-muted">${count}</span>
250
- </div>`
251
- ).join('');
252
-
253
- container.innerHTML = `
254
- <div style="display:flex;align-items:center;gap:var(--space-4)">
255
- <svg width="120" height="120" viewBox="0 0 120 120" class="donut-svg">
256
- <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="${sw}" />
257
- ${arcs.join('')}
258
- </svg>
259
- <div class="donut-legend">${legend}</div>
260
- </div>
261
- `;
262
- }
263
-
264
- function _renderVelocityChart() {
265
- const container = document.getElementById('portfolio-velocity-container');
266
- if (!container) return;
267
-
268
- const allMetrics = Array.from(_metricsCache.values());
269
- let totalCompleted = 0, totalAll = 0;
270
- for (const m of allMetrics) {
271
- totalCompleted += m.totals?.completed || 0;
272
- totalAll += m.totals?.all || 0;
273
- }
274
- const totalPending = totalAll - totalCompleted;
275
-
276
- const barW = 60, barH = 80;
277
- const completedH = totalAll ? Math.round((totalCompleted / totalAll) * barH) : 0;
278
- const pendingH = totalAll ? Math.round((totalPending / totalAll) * barH) : 0;
279
-
280
- container.innerHTML = `
281
- <div style="display:flex;align-items:flex-end;gap:var(--space-4);height:${barH + 30}px;padding-top:var(--space-3)">
282
- <div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-1)">
283
- <span class="text-muted" style="font-size:var(--text-xs)">${totalCompleted}</span>
284
- <div style="width:${barW}px;height:${completedH}px;background:var(--success);border-radius:var(--radius-xs) var(--radius-xs) 0 0;min-height:4px"></div>
285
- <span style="font-size:var(--text-xs);font-weight:600;color:var(--text-secondary)">${t('ui.projects.completed', {}, 'Done')}</span>
286
- </div>
287
- <div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-1)">
288
- <span class="text-muted" style="font-size:var(--text-xs)">${totalPending}</span>
289
- <div style="width:${barW}px;height:${pendingH}px;background:var(--accent);border-radius:var(--radius-xs) var(--radius-xs) 0 0;min-height:4px"></div>
290
- <span style="font-size:var(--text-xs);font-weight:600;color:var(--text-secondary)">${t('ui.projects.remaining', {}, 'Remaining')}</span>
291
- </div>
292
- </div>
293
- `;
294
- }
295
-
296
- function _renderAttentionFeed() {
297
- const container = document.getElementById('portfolio-attention-feed');
298
- if (!container) return;
299
-
300
- const alerts = [];
301
- const projects = state.get('projects') || [];
302
-
303
- for (const [id, m] of _metricsCache) {
304
- const project = projects.find(p => p.id === id);
305
- const name = project?.name || id;
306
-
307
- if (m.totals?.blocked > 0) {
308
- alerts.push({ severity: 'danger', text: `${name}: ${m.totals.blocked} ${t('ui.projects.blockedTasks', {}, 'blocked tasks')}` });
309
- }
310
-
311
- const health = _computeHealth(m, project);
312
- if (health.status === 'delayed') {
313
- alerts.push({ severity: 'danger', text: `${name}: ${t('ui.projects.delayed', {}, 'Delayed')}` });
314
- } else if (health.status === 'at-risk') {
315
- alerts.push({ severity: 'warning', text: `${name}: ${t('ui.projects.atRisk', {}, 'At risk')}` });
316
- }
317
- }
318
-
319
- if (alerts.length === 0) {
320
- container.innerHTML = `<p class="text-muted" style="font-size:var(--text-xs);padding:var(--space-2) 0">${t('ui.projects.allClear', {}, 'All projects on track')}</p>`;
321
- return;
322
- }
323
-
324
- container.innerHTML = alerts.slice(0, 5).map(a => `
325
- <div class="attention-item">
326
- <span class="attention-dot" style="background:var(--${a.severity})"></span>
327
- <span style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(a.text)}</span>
328
- </div>
329
- `).join('');
330
- }
331
-
332
- // ─────────────────────────────── HEALTH COMPUTATION ──────────────────────────
333
-
334
- function _computeHealth(metrics, project) {
335
- if (!metrics) return { status: 'unknown', label: '—', color: 'var(--text-muted)' };
336
-
337
- const pct = metrics.totals?.all ? (metrics.totals.completed / metrics.totals.all) : 0;
338
- const deadline = _getProjectDeadline(metrics, project);
339
-
340
- if (deadline) {
341
- const now = Date.now();
342
- const deadlineTime = new Date(deadline).getTime();
343
- if (deadlineTime < now && pct < 1) {
344
- return { status: 'delayed', label: t('ui.projects.health.delayed', {}, 'Delayed'), color: 'var(--danger)' };
345
- }
346
- const daysLeft = Math.ceil((deadlineTime - now) / 86400000);
347
- if (daysLeft <= 7 && pct < 0.8) {
348
- return { status: 'at-risk', label: t('ui.projects.health.atRisk', {}, 'At risk'), color: 'var(--warning)' };
349
- }
350
- }
351
-
352
- if (metrics.totals?.blocked > 0 && metrics.totals.blocked >= (metrics.totals.inProgress || 1)) {
353
- return { status: 'at-risk', label: t('ui.projects.health.atRisk', {}, 'At risk'), color: 'var(--warning)' };
354
- }
355
-
356
- return { status: 'on-track', label: t('ui.projects.health.onTrack', {}, 'On track'), color: 'var(--success)' };
357
- }
358
-
359
- function _getProjectDeadline(metrics, project) {
360
- if (!metrics?.milestones?.length) return null;
361
- const sorted = [...metrics.milestones].sort((a, b) => new Date(b.date) - new Date(a.date));
362
- return sorted[0]?.date || null;
363
- }
364
-
365
- function _getDeadlineInfo(metrics, project) {
366
- const deadline = _getProjectDeadline(metrics, project);
367
- if (!deadline) return null;
368
- const d = new Date(deadline);
369
- const now = Date.now();
370
- const diff = Math.ceil((d.getTime() - now) / 86400000);
371
- return { date: deadline, daysLeft: diff };
372
- }
373
-
374
- function _getPriority(metrics) {
375
- if (!metrics?.totals) return null;
376
- const { blocked, inProgress, all, completed } = metrics.totals;
377
- const pct = all ? completed / all : 0;
378
- if (blocked > 2 || pct < 0.3) return { label: t('ui.projects.priority.high', {}, 'High'), color: 'var(--danger)' };
379
- if (blocked > 0 || pct < 0.6) return { label: t('ui.projects.priority.medium', {}, 'Medium'), color: 'var(--warning)' };
380
- return { label: t('ui.projects.priority.low', {}, 'Low'), color: 'var(--success)' };
381
- }
382
-
383
- // ─────────────────────────────── CARD RENDER ─────────────────────────────────
384
-
385
- function _renderProjectCard(project, isCurrent) {
386
- const cached = _metricsCache.get(project.id);
387
-
388
- return `
389
- <article class="glass-card project-card ${isCurrent ? 'is-current project-card--active' : ''} ${!project.available ? 'is-unavailable' : ''}" data-project-id="${esc(project.id)}" aria-label="${esc(project.name)}" ${isCurrent ? 'style="border-color: var(--accent); border-width: 2px"' : ''}>
390
- <div class="project-card-header">
391
- <div class="project-card-info">
392
- <div class="project-card-name-row">
393
- <h3 class="project-card-name">${esc(project.name)}</h3>
394
- <button class="project-info-btn" type="button" data-tooltip-trigger data-project-id="${esc(project.id)}" aria-label="${t('ui.projects.moreInfo', {}, 'More info')}">
395
- ${icon('info', 14)}
396
- </button>
397
- <div class="project-info-tooltip is-hidden" data-tooltip-for="${esc(project.id)}">
398
- <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.path', {}, 'Path')}:</span> <span class="tooltip-value">${esc(project.root || project.workspaceRoot || '—')}</span></div>
399
- <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.registered', {}, 'Registered')}:</span> <span class="tooltip-value">${project.registeredAt ? formatDate(project.registeredAt, 'date') : '—'}</span></div>
400
- <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.lastSeen', {}, 'Last seen')}:</span> <span class="tooltip-value">${project.lastSeenAt ? formatDate(project.lastSeenAt, 'date') : '—'}</span></div>
401
- ${cached ? `<div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.deadline', {}, 'Deadline')}:</span> <span class="tooltip-value">${_getDeadlineInfo(cached, project) ? formatDate(_getDeadlineInfo(cached, project).date, 'date') : '—'}</span></div>` : ''}
402
- </div>
403
- </div>
404
- </div>
405
- <div class="project-card-badges">
406
- ${isCurrent ? `<span class="badge badge-accent">${t('ui.projects.current', {}, 'Current')}</span>` : ''}
407
- <span class="badge badge-${project.available ? 'success' : 'warning'}">
408
- <span class="repo-badge-dot" style="width:6px;height:6px;border-radius:50%;background:${project.available ? 'var(--success)' : 'var(--warning)'};flex-shrink:0"></span>
409
- ${project.available ? t('ui.projects.available', {}, 'Available') : t('ui.projects.unavailable', {}, 'Unavailable')}
410
- </span>
411
- ${project.layout ? `<span class="badge badge-muted">${esc(project.layout)}</span>` : ''}
412
- </div>
413
- </div>
414
-
415
- ${project.available && cached ? _renderCardIndicators(cached, project) : ''}
416
-
417
- ${project.available ? `
418
- <div class="project-card-metrics" id="metrics-${esc(project.id)}">
419
- ${cached ? _renderMetrics(cached) : _renderMetricsSkeleton()}
420
- </div>
421
-
422
- <div class="project-card-progress" id="progress-${esc(project.id)}">
423
- ${cached ? _renderProgressBar(cached) : '<div class="skeleton skeleton-text" style="height:8px;width:100%"></div>'}
424
- </div>
425
- ` : `
426
- <div class="project-card-metrics is-disabled">
427
- <p class="text-muted" style="font-size:var(--text-xs);padding:var(--space-4) 0">${t('ui.projects.unavailableDesc', {}, 'Project directory not accessible.')}</p>
428
- </div>
429
- `}
430
-
431
- <div class="project-card-actions">
432
- ${project.available && !isCurrent ? `
433
- <button class="btn btn-primary btn-sm" type="button" data-action="open" data-project="${esc(project.id)}" aria-label="${t('ui.projects.open', {}, 'Open project')}">
434
- ${icon('externalLink', 14)} ${t('ui.projects.open', {}, 'Open')}
435
- </button>
436
- ` : ''}
437
- ${project.available ? `
438
- <button class="btn btn-ghost btn-sm" type="button" data-action="refresh" data-project="${esc(project.id)}" aria-label="${t('ui.projects.refresh', {}, 'Refresh')}">
439
- ${icon('refresh', 14)} ${t('ui.projects.refresh', {}, 'Refresh')}
440
- </button>
441
- <button class="btn btn-ghost btn-sm" type="button" data-action="sync" data-project="${esc(project.id)}" aria-label="${t('ui.projects.syncDocs', {}, 'Sync docs')}">
442
- ${icon('sync', 14)} ${t('ui.projects.syncDocs', {}, 'Sync')}
443
- </button>
444
- ` : ''}
445
- <button class="btn btn-ghost btn-sm" style="color:var(--danger)" type="button" data-action="remove" data-project="${esc(project.id)}" aria-label="${t('ui.projects.remove', {}, 'Remove')}">
446
- ${icon('trash', 14)} ${t('ui.projects.remove', {}, 'Remove')}
447
- </button>
448
- </div>
449
- </article>
450
- `;
451
- }
452
-
453
- function _renderCardIndicators(metrics, project) {
454
- const health = _computeHealth(metrics, project);
455
- const deadline = _getDeadlineInfo(metrics, project);
456
- const priority = _getPriority(metrics);
457
-
458
- return `
459
- <div class="project-card-indicators">
460
- <div class="project-indicator">
461
- <span class="indicator-dot" style="background:${health.color}"></span>
462
- <span class="indicator-label">${health.label}</span>
463
- </div>
464
- ${deadline ? `
465
- <div class="project-indicator">
466
- ${icon('calendar', 12)}
467
- <span class="indicator-label ${deadline.daysLeft < 0 ? 'text-danger' : deadline.daysLeft <= 7 ? 'text-warning' : ''}">${
468
- deadline.daysLeft < 0
469
- ? t('ui.projects.overdue', { days: Math.abs(deadline.daysLeft) }, `${Math.abs(deadline.daysLeft)}d overdue`)
470
- : deadline.daysLeft === 0
471
- ? t('ui.projects.dueToday', {}, 'Due today')
472
- : t('ui.projects.daysLeft', { days: deadline.daysLeft }, `${deadline.daysLeft}d left`)
473
- }</span>
474
- </div>
475
- ` : ''}
476
- ${priority ? `
477
- <div class="project-indicator">
478
- <span class="indicator-label" style="color:${priority.color};font-weight:700">${priority.label}</span>
479
- </div>
480
- ` : ''}
481
- </div>
482
- `;
483
- }
484
-
485
- function _renderMetrics(metrics) {
486
- const { totals, activePhase } = metrics;
487
- const completionPct = totals.all ? Math.round((totals.completed / totals.all) * 100) : 0;
488
-
489
- return `
490
- <div class="project-metrics-grid">
491
- <div class="glass-card project-metric">
492
- <span class="project-metric-value">${totals.all}</span>
493
- <span class="project-metric-label">${t('ui.projects.metric.tasks', {}, 'Tasks')}</span>
494
- </div>
495
- <div class="glass-card project-metric">
496
- <span class="project-metric-value" style="color:var(--success)">${totals.completed}</span>
497
- <span class="project-metric-label">${t('ui.projects.metric.done', {}, 'Done')}</span>
498
- </div>
499
- <div class="glass-card project-metric">
500
- <span class="project-metric-value" style="color:${totals.blocked > 0 ? 'var(--danger)' : 'var(--text-primary)'}">${totals.blocked}</span>
501
- <span class="project-metric-label">${t('ui.projects.metric.blocked', {}, 'Blocked')}</span>
502
- </div>
503
- <div class="glass-card project-metric">
504
- <span class="project-metric-value" style="color:var(--accent)">${_phaseLabel(activePhase?.id)}</span>
505
- <span class="project-metric-label">${t('ui.projects.metric.phase', {}, 'Phase')}</span>
506
- </div>
507
- <div class="glass-card project-metric">
508
- <span class="project-metric-value">${completionPct}%</span>
509
- <span class="project-metric-label">${t('ui.projects.metric.progress', {}, 'Progress')}</span>
510
- </div>
511
- </div>
512
- `;
513
- }
514
-
515
- function _renderProgressBar(metrics) {
516
- const pct = metrics.totals.all ? Math.round((metrics.totals.completed / metrics.totals.all) * 100) : 0;
517
- return `
518
- <div class="project-progress-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="Completion: ${pct}%">
519
- <div class="project-progress-fill" style="width:${pct}%"></div>
520
- </div>
521
- `;
522
- }
523
-
524
- function _renderMetricsSkeleton() {
525
- return `
526
- <div class="project-metrics-grid">
527
- ${Array.from({ length: 5 }, () => `
528
- <div class="glass-card project-metric">
529
- <div class="skeleton" style="width:32px;height:20px;margin:0 auto var(--space-1)"></div>
530
- <div class="skeleton skeleton-text" style="width:40px;height:10px;margin:0 auto"></div>
531
- </div>
532
- `).join('')}
533
- </div>
534
- `;
535
- }
536
-
537
- // ─────────────────────────────── TOOLTIP ──────────────────────────────────────
538
-
539
- function _handleTooltipShow(e) {
540
- const trigger = e.target.closest('[data-tooltip-trigger]');
541
- if (!trigger) return;
542
- const projectId = trigger.dataset.projectId;
543
- const tooltip = document.querySelector(`[data-tooltip-for="${projectId}"]`);
544
- if (tooltip) tooltip.classList.remove('is-hidden');
545
- }
546
-
547
- function _handleTooltipHide(e) {
548
- const trigger = e.target.closest('[data-tooltip-trigger]');
549
- if (!trigger) return;
550
- const projectId = trigger.dataset.projectId;
551
- const tooltip = document.querySelector(`[data-tooltip-for="${projectId}"]`);
552
- if (tooltip) tooltip.classList.add('is-hidden');
553
- }
554
-
555
- // ─────────────────────────────── METRICS LOADING ─────────────────────────────
556
-
557
- async function _loadAllMetrics() {
558
- const projects = (state.get('projects') || []).filter(p => p.available);
559
-
560
- await Promise.allSettled(
561
- projects.map(async (p) => {
562
- try {
563
- const payload = await api.getProjectState(p.id);
564
- const metrics = {
565
- totals: payload.derived?.totals || { all: 0, completed: 0, blocked: 0, pending: 0, inProgress: 0, inReview: 0, cancelled: 0 },
566
- activePhase: payload.derived?.activePhase || null,
567
- runtime: payload.runtime || null,
568
- opera: payload.opera || null,
569
- milestones: payload.control?.milestones || [],
570
- tasks: payload.control?.tasks || [],
571
- };
572
- _metricsCache.set(p.id, metrics);
573
- _updateCardMetrics(p.id, metrics);
574
- } catch (err) {
575
- console.warn(`[projects] Could not load metrics for ${p.name}:`, err.message);
576
- }
577
- })
578
- );
579
-
580
- // After all metrics loaded, update portfolio analytics
581
- _updatePortfolioAnalytics();
582
- }
583
-
584
- function _updateCardMetrics(projectId, metrics) {
585
- const metricsEl = document.getElementById(`metrics-${projectId}`);
586
- if (metricsEl) metricsEl.innerHTML = _renderMetrics(metrics);
587
-
588
- const progressEl = document.getElementById(`progress-${projectId}`);
589
- if (progressEl) progressEl.innerHTML = _renderProgressBar(metrics);
590
-
591
- // Update indicators
592
- const card = document.querySelector(`[data-project-id="${projectId}"]`);
593
- if (card) {
594
- const existingIndicators = card.querySelector('.project-card-indicators');
595
- const project = (state.get('projects') || []).find(p => p.id === projectId);
596
- const indicatorsHtml = _renderCardIndicators(metrics, project);
597
- if (existingIndicators) {
598
- existingIndicators.outerHTML = indicatorsHtml;
599
- } else {
600
- const header = card.querySelector('.project-card-header');
601
- if (header) header.insertAdjacentHTML('afterend', indicatorsHtml);
602
- }
603
- }
604
- }
605
-
606
- // ─────────────────────────────── ACTIONS ─────────────────────────────────────
607
-
608
- function _handleCardAction(e) {
609
- const btn = e.target.closest('[data-action]');
610
- if (!btn) return;
611
- const action = btn.dataset.action;
612
- const projectId = btn.dataset.project;
613
- switch (action) {
614
- case 'open': _openProject(projectId); break;
615
- case 'refresh': _refreshProject(projectId); break;
616
- case 'sync': _syncProject(projectId); break;
617
- case 'remove': _removeProject(projectId); break;
618
- }
619
- }
620
-
621
- async function _openProject(projectId) {
622
- state.update('currentProjectId', projectId);
623
- localStorage.setItem('ops-dashboard-project', projectId);
624
- window.dispatchEvent(new CustomEvent('ops:refresh'));
625
- router.navigate('dashboard');
626
- flash(t('ui.projects.opened', {}, 'Project opened'), 'success');
627
- }
628
-
629
- async function _refreshProject(projectId) {
630
- const btn = document.querySelector(`[data-action="refresh"][data-project="${projectId}"]`);
631
- if (btn) btn.disabled = true;
632
- try {
633
- await api.getProjectState(projectId);
634
- _metricsCache.delete(projectId);
635
- await _loadAllMetrics();
636
- flash(t('ui.projects.refreshed', {}, 'Project refreshed'), 'success');
637
- } catch (err) {
638
- flash(`Error: ${err.message}`, 'error');
639
- } finally {
640
- if (btn) btn.disabled = false;
641
- }
642
- }
643
-
644
- async function _syncProject(projectId) {
645
- try {
646
- await api.call('/api/sync', {
647
- method: 'POST',
648
- body: JSON.stringify({ projectId }),
649
- projectAware: false,
650
- });
651
- flash(t('ui.projects.synced', {}, 'Docs synced'), 'success');
652
- } catch (err) {
653
- flash(`Sync error: ${err.message}`, 'error');
654
- }
655
- }
656
-
657
- async function _removeProject(projectId) {
658
- const project = (state.get('projects') || []).find(p => p.id === projectId);
659
- if (!project) return;
660
- const confirmed = confirm(
661
- t('ui.projects.removeConfirm', { name: project.name },
662
- `Remove "${project.name}" from the registry? This will NOT delete any files.`)
663
- );
664
- if (!confirmed) return;
665
- try {
666
- const result = await api.removeProject(projectId);
667
- state.update('projects', result.projects || []);
668
- _metricsCache.delete(projectId);
669
- if (state.get('currentProjectId') === projectId) {
670
- const first = (result.projects || []).find(p => p.available);
671
- state.update('currentProjectId', first?.id || null);
672
- if (first) localStorage.setItem('ops-dashboard-project', first.id);
673
- }
674
- router.refresh();
675
- flash(t('ui.projects.removed', {}, 'Project removed from registry'), 'success');
676
- } catch (err) {
677
- flash(`Error: ${err.message}`, 'error');
678
- }
679
- }
680
-
681
- async function _purgeUnavailable() {
682
- const projects = state.get('projects') || [];
683
- const count = projects.filter(p => !p.available).length;
684
- if (!count) return;
685
- const confirmed = confirm(
686
- `Remove ${count} unavailable project${count !== 1 ? 's' : ''} from the registry? This will NOT delete any files.`
687
- );
688
- if (!confirmed) return;
689
- const btn = document.getElementById('projects-purge-unavailable');
690
- if (btn) btn.disabled = true;
691
- try {
692
- const result = await api.purgeUnavailableProjects();
693
- state.update('projects', result.projects || []);
694
- _metricsCache.clear();
695
- router.refresh();
696
- flash(`Removed ${result.removed} unavailable project${result.removed !== 1 ? 's' : ''}`, 'success');
697
- } catch (err) {
698
- flash(`Error: ${err.message}`, 'error');
699
- if (btn) btn.disabled = false;
700
- }
701
- }
702
-
703
- async function _refreshAll() {
704
- _metricsCache.clear();
705
- try {
706
- const result = await api.getProjects();
707
- state.update('projects', result.projects || []);
708
- router.refresh();
709
- } catch (err) {
710
- flash(`Error: ${err.message}`, 'error');
711
- }
712
- }
713
-
714
- // ─────────────────────────────── MODAL ───────────────────────────────────────
715
-
716
- function _showRegisterModal() {
717
- const modal = document.getElementById('register-modal');
718
- if (modal) {
719
- modal.classList.remove('is-hidden');
720
- document.getElementById('register-path')?.focus();
721
- }
722
- }
723
-
724
- function _hideRegisterModal() {
725
- const modal = document.getElementById('register-modal');
726
- if (modal) modal.classList.add('is-hidden');
727
- }
728
-
729
- async function _confirmRegister() {
730
- const input = document.getElementById('register-path');
731
- const path = input?.value?.trim();
732
- if (!path) {
733
- flash(t('ui.projects.pathRequired', {}, 'Please enter a project path'), 'warning');
734
- return;
735
- }
736
- try {
737
- const result = await api.registerProject(path);
738
- state.update('projects', result.projects || []);
739
- _hideRegisterModal();
740
- router.refresh();
741
- flash(t('ui.projects.registered', {}, 'Project registered'), 'success');
742
- } catch (err) {
743
- flash(`Error: ${err.message}`, 'error');
744
- }
745
- }
1
+ /**
2
+ * projects.js — Vista de gestion de espacios de trabajo registrados
3
+ * Cards por proyecto con metricas lazy-loaded, KPIs agregados, charts, tooltips.
4
+ */
5
+
6
+ import { icon } from '../icons.js';
7
+ import * as state from '../state.js';
8
+ import * as api from '../api.js';
9
+ import * as router from '../router.js';
10
+ import { esc, formatDate, formatDurationShort } from '../utils.js';
11
+ import { flash } from './flash.js';
12
+ import { t } from '../i18n.js';
13
+
14
+ /** Cache de metricas por proyecto */
15
+ const _metricsCache = new Map();
16
+
17
+ /** Map OPERA phase IDs to standard dev labels */
18
+ const PHASE_LABELS = {
19
+ O: { es: 'Diseño', en: 'Design' },
20
+ P: { es: 'QA', en: 'QA' },
21
+ E: { es: 'Dev', en: 'Dev' },
22
+ R: { es: 'Polish', en: 'Polish' },
23
+ A: { es: 'Deploy', en: 'Deploy' },
24
+ };
25
+
26
+ function _phaseLabel(phaseId) {
27
+ const locale = state.get('locale') || 'es';
28
+ const map = PHASE_LABELS[phaseId];
29
+ return map ? map[locale] || map.en : phaseId || '—';
30
+ }
31
+
32
+ export async function render() {
33
+ const projects = state.get('projects') || [];
34
+ const currentId = state.get('currentProjectId');
35
+ const unavailableCount = projects.filter(p => !p.available).length;
36
+
37
+ return `
38
+ <div class="view-enter">
39
+ <div class="section-header">
40
+ <div class="section-header-left">
41
+ <p class="eyebrow">${t('ui.projects.eyebrow', {}, 'Workspaces')}</p>
42
+ <h2>${t('ui.projects.title', {}, 'Managed projects')} <span style="font-size:var(--text-sm);font-weight:400;color:var(--text-secondary)">(${projects.length})</span></h2>
43
+ </div>
44
+ <div class="section-header-right">
45
+ ${unavailableCount > 0 ? `
46
+ <button class="btn btn-ghost btn-sm" style="color:var(--danger)" type="button" id="projects-purge-unavailable" aria-label="Purge unavailable projects">
47
+ ${icon('trash', 16)} Purge unavailable (${unavailableCount})
48
+ </button>
49
+ ` : ''}
50
+ <button class="btn btn-ghost btn-sm" type="button" id="projects-refresh-all" aria-label="${t('ui.projects.refreshAll', {}, 'Refresh all')}">
51
+ ${icon('refresh', 16)} ${t('ui.projects.refreshAll', {}, 'Refresh all')}
52
+ </button>
53
+ <button class="btn btn-primary btn-sm" type="button" id="projects-register-new" aria-label="${t('ui.projects.register', {}, 'Register project')}">
54
+ ${icon('plus', 16)} ${t('ui.projects.register', {}, 'Register project')}
55
+ </button>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Portfolio KPIs -->
60
+ <div class="portfolio-kpis" id="portfolio-kpis">
61
+ ${_renderPortfolioKPIs()}
62
+ </div>
63
+
64
+ <!-- Attention feed + Charts row -->
65
+ <div class="portfolio-analytics" id="portfolio-analytics">
66
+ <div class="glass-card portfolio-chart" id="portfolio-status-donut">
67
+ <h4 class="chart-title">${t('ui.projects.statusDistribution', {}, 'Status distribution')}</h4>
68
+ <div class="portfolio-donut-container" id="portfolio-donut-container">
69
+ <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
70
+ </div>
71
+ </div>
72
+ <div class="glass-card portfolio-chart" id="portfolio-velocity-chart">
73
+ <h4 class="chart-title">${t('ui.projects.velocity', {}, 'Resolution velocity')}</h4>
74
+ <div id="portfolio-velocity-container">
75
+ <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
76
+ </div>
77
+ </div>
78
+ <div class="glass-card portfolio-attention" id="portfolio-attention">
79
+ <h4 class="chart-title">${t('ui.projects.attention', {}, 'Attention required')}</h4>
80
+ <div id="portfolio-attention-feed">
81
+ <p class="text-muted" style="font-size:var(--text-xs)">${t('ui.projects.loading', {}, 'Loading...')}</p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ ${projects.length === 0
87
+ ? `<div class="empty-state" style="margin:3rem auto;max-width:440px">
88
+ ${icon('folder', 32)}
89
+ <h3>${t('ui.projects.noProjects', {}, 'No projects registered')}</h3>
90
+ <p style="font-size:var(--text-sm);color:var(--text-secondary)">${t('ui.projects.noProjectsDesc', {}, 'Register a project directory to start tracking.')}</p>
91
+ <button class="btn btn-primary" type="button" id="projects-register-empty">${icon('plus', 16)} ${t('ui.projects.register', {}, 'Register project')}</button>
92
+ </div>`
93
+ : `<div class="projects-grid" id="projects-grid">
94
+ ${projects.map(p => _renderProjectCard(p, p.id === currentId)).join('')}
95
+ </div>`
96
+ }
97
+
98
+ <!-- Register modal -->
99
+ <div class="modal-overlay is-hidden" id="register-modal">
100
+ <div class="glass-card modal" role="dialog" aria-modal="true" aria-labelledby="register-modal-title">
101
+ <div class="modal-header">
102
+ <h2 class="modal-title" id="register-modal-title">${icon('folder', 18)} ${t('ui.projects.registerTitle', {}, 'Register project')}</h2>
103
+ <button class="modal-close" type="button" id="register-modal-close" aria-label="Close">&times;</button>
104
+ </div>
105
+ <div class="modal-body">
106
+ <label class="form-label" for="register-path">${t('ui.projects.pathLabel', {}, 'Project directory path')}</label>
107
+ <input class="form-input" type="text" id="register-path" placeholder="/home/user/my-project" style="width:100%" />
108
+ <p style="font-size:var(--text-xs);color:var(--text-muted);margin-top:var(--space-2)">${t('ui.projects.pathDesc', {}, 'The directory must contain a project_control.json or be initializable with TrackOps.')}</p>
109
+ </div>
110
+ <div class="modal-footer">
111
+ <button class="btn btn-ghost" type="button" id="register-cancel">${t('ui.projects.cancel', {}, 'Cancel')}</button>
112
+ <button class="btn btn-primary" type="button" id="register-confirm">${icon('plus', 14)} ${t('ui.projects.confirm', {}, 'Register')}</button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ `;
118
+ }
119
+
120
+ export function bindEvents() {
121
+ document.getElementById('projects-purge-unavailable')?.addEventListener('click', _purgeUnavailable);
122
+ document.getElementById('projects-refresh-all')?.addEventListener('click', _refreshAll);
123
+ document.getElementById('projects-register-new')?.addEventListener('click', _showRegisterModal);
124
+ document.getElementById('projects-register-empty')?.addEventListener('click', _showRegisterModal);
125
+ document.getElementById('register-modal-close')?.addEventListener('click', _hideRegisterModal);
126
+ document.getElementById('register-cancel')?.addEventListener('click', _hideRegisterModal);
127
+ document.getElementById('register-confirm')?.addEventListener('click', _confirmRegister);
128
+ document.getElementById('register-modal')?.addEventListener('click', (e) => {
129
+ if (e.target.id === 'register-modal') _hideRegisterModal();
130
+ });
131
+ document.getElementById('projects-grid')?.addEventListener('click', _handleCardAction);
132
+
133
+ // Tooltip hover delegation
134
+ document.getElementById('projects-grid')?.addEventListener('mouseover', _handleTooltipShow);
135
+ document.getElementById('projects-grid')?.addEventListener('mouseout', _handleTooltipHide);
136
+
137
+ _loadAllMetrics();
138
+ }
139
+
140
+ // ─────────────────────────────── PORTFOLIO KPIs ──────────────────────────────
141
+
142
+ function _renderPortfolioKPIs() {
143
+ const allMetrics = Array.from(_metricsCache.values());
144
+ const activeProjects = (state.get('projects') || []).filter(p => p.available).length;
145
+ const totalBlocked = allMetrics.reduce((s, m) => s + (m.totals?.blocked || 0), 0);
146
+ const upcomingDeadlines = _getUpcomingMilestones(7).length;
147
+
148
+ return `
149
+ <div class="portfolio-kpi-grid">
150
+ <div class="glass-card portfolio-kpi">
151
+ <span class="portfolio-kpi-icon">${icon('folder', 20)}</span>
152
+ <div>
153
+ <span class="portfolio-kpi-value">${activeProjects}</span>
154
+ <span class="portfolio-kpi-label">${t('ui.projects.kpi.active', {}, 'Active projects')}</span>
155
+ </div>
156
+ </div>
157
+ <div class="glass-card portfolio-kpi ${totalBlocked > 0 ? 'kpi-danger' : ''}">
158
+ <span class="portfolio-kpi-icon">${icon('alertCircle', 20)}</span>
159
+ <div>
160
+ <span class="portfolio-kpi-value" ${totalBlocked > 0 ? 'style="color:var(--danger)"' : ''}>${totalBlocked}</span>
161
+ <span class="portfolio-kpi-label">${t('ui.projects.kpi.blocked', {}, 'Blocked tasks')}</span>
162
+ </div>
163
+ </div>
164
+ <div class="glass-card portfolio-kpi ${upcomingDeadlines > 0 ? 'kpi-warning' : ''}">
165
+ <span class="portfolio-kpi-icon">${icon('calendar', 20)}</span>
166
+ <div>
167
+ <span class="portfolio-kpi-value" ${upcomingDeadlines > 0 ? 'style="color:var(--warning)"' : ''}>${upcomingDeadlines}</span>
168
+ <span class="portfolio-kpi-label">${t('ui.projects.kpi.deadlines', {}, 'Deadlines this week')}</span>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ `;
173
+ }
174
+
175
+ function _getUpcomingMilestones(days) {
176
+ const now = Date.now();
177
+ const limit = now + days * 86400000;
178
+ const results = [];
179
+ for (const [id, m] of _metricsCache) {
180
+ if (!m.milestones) continue;
181
+ for (const ms of m.milestones) {
182
+ const d = new Date(ms.date).getTime();
183
+ if (d >= now && d <= limit) {
184
+ const project = (state.get('projects') || []).find(p => p.id === id);
185
+ results.push({ ...ms, projectName: project?.name || id });
186
+ }
187
+ }
188
+ }
189
+ return results;
190
+ }
191
+
192
+ // ─────────────────────────────── PORTFOLIO ANALYTICS ─────────────────────────
193
+
194
+ function _updatePortfolioAnalytics() {
195
+ // Update KPIs
196
+ const kpisEl = document.getElementById('portfolio-kpis');
197
+ if (kpisEl) kpisEl.innerHTML = _renderPortfolioKPIs();
198
+
199
+ // Status donut
200
+ _renderStatusDonut();
201
+
202
+ // Velocity
203
+ _renderVelocityChart();
204
+
205
+ // Attention feed
206
+ _renderAttentionFeed();
207
+ }
208
+
209
+ function _renderStatusDonut() {
210
+ const container = document.getElementById('portfolio-donut-container');
211
+ if (!container) return;
212
+
213
+ const allMetrics = Array.from(_metricsCache.values());
214
+ const phases = {};
215
+ for (const m of allMetrics) {
216
+ const phaseId = m.activePhase?.id || '?';
217
+ const label = _phaseLabel(phaseId);
218
+ phases[label] = (phases[label] || 0) + 1;
219
+ }
220
+
221
+ const entries = Object.entries(phases);
222
+ if (entries.length === 0) {
223
+ container.innerHTML = `<p class="text-muted" style="font-size:var(--text-xs)">No data</p>`;
224
+ return;
225
+ }
226
+
227
+ const total = entries.reduce((s, [, v]) => s + v, 0);
228
+ const colors = ['var(--accent)', 'var(--success)', 'var(--warning)', 'var(--danger)', 'var(--info)'];
229
+ const r = 40, cx = 60, cy = 60, sw = 12;
230
+ const circ = 2 * Math.PI * r;
231
+ let offset = 0;
232
+
233
+ const arcs = entries.map(([label, count], i) => {
234
+ const pct = count / total;
235
+ const dashLen = pct * circ;
236
+ const arc = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
237
+ stroke="${colors[i % colors.length]}" stroke-width="${sw}"
238
+ stroke-dasharray="${dashLen} ${circ - dashLen}"
239
+ stroke-dashoffset="${-offset}" stroke-linecap="round"
240
+ transform="rotate(-90 ${cx} ${cy})" />`;
241
+ offset += dashLen;
242
+ return arc;
243
+ });
244
+
245
+ const legend = entries.map(([label, count], i) =>
246
+ `<div class="donut-legend-item">
247
+ <span class="donut-legend-dot" style="background:${colors[i % colors.length]}"></span>
248
+ <span>${label}</span>
249
+ <span class="text-muted">${count}</span>
250
+ </div>`
251
+ ).join('');
252
+
253
+ container.innerHTML = `
254
+ <div style="display:flex;align-items:center;gap:var(--space-4)">
255
+ <svg width="120" height="120" viewBox="0 0 120 120" class="donut-svg">
256
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="${sw}" />
257
+ ${arcs.join('')}
258
+ </svg>
259
+ <div class="donut-legend">${legend}</div>
260
+ </div>
261
+ `;
262
+ }
263
+
264
+ function _renderVelocityChart() {
265
+ const container = document.getElementById('portfolio-velocity-container');
266
+ if (!container) return;
267
+
268
+ const allMetrics = Array.from(_metricsCache.values());
269
+ let totalCompleted = 0, totalAll = 0;
270
+ for (const m of allMetrics) {
271
+ totalCompleted += m.totals?.completed || 0;
272
+ totalAll += m.totals?.all || 0;
273
+ }
274
+ const totalPending = totalAll - totalCompleted;
275
+
276
+ const barW = 60, barH = 80;
277
+ const completedH = totalAll ? Math.round((totalCompleted / totalAll) * barH) : 0;
278
+ const pendingH = totalAll ? Math.round((totalPending / totalAll) * barH) : 0;
279
+
280
+ container.innerHTML = `
281
+ <div style="display:flex;align-items:flex-end;gap:var(--space-4);height:${barH + 30}px;padding-top:var(--space-3)">
282
+ <div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-1)">
283
+ <span class="text-muted" style="font-size:var(--text-xs)">${totalCompleted}</span>
284
+ <div style="width:${barW}px;height:${completedH}px;background:var(--success);border-radius:var(--radius-xs) var(--radius-xs) 0 0;min-height:4px"></div>
285
+ <span style="font-size:var(--text-xs);font-weight:600;color:var(--text-secondary)">${t('ui.projects.completed', {}, 'Done')}</span>
286
+ </div>
287
+ <div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-1)">
288
+ <span class="text-muted" style="font-size:var(--text-xs)">${totalPending}</span>
289
+ <div style="width:${barW}px;height:${pendingH}px;background:var(--accent);border-radius:var(--radius-xs) var(--radius-xs) 0 0;min-height:4px"></div>
290
+ <span style="font-size:var(--text-xs);font-weight:600;color:var(--text-secondary)">${t('ui.projects.remaining', {}, 'Remaining')}</span>
291
+ </div>
292
+ </div>
293
+ `;
294
+ }
295
+
296
+ function _renderAttentionFeed() {
297
+ const container = document.getElementById('portfolio-attention-feed');
298
+ if (!container) return;
299
+
300
+ const alerts = [];
301
+ const projects = state.get('projects') || [];
302
+
303
+ for (const [id, m] of _metricsCache) {
304
+ const project = projects.find(p => p.id === id);
305
+ const name = project?.name || id;
306
+
307
+ if (m.totals?.blocked > 0) {
308
+ alerts.push({ severity: 'danger', text: `${name}: ${m.totals.blocked} ${t('ui.projects.blockedTasks', {}, 'blocked tasks')}` });
309
+ }
310
+
311
+ const health = _computeHealth(m, project);
312
+ if (health.status === 'delayed') {
313
+ alerts.push({ severity: 'danger', text: `${name}: ${t('ui.projects.delayed', {}, 'Delayed')}` });
314
+ } else if (health.status === 'at-risk') {
315
+ alerts.push({ severity: 'warning', text: `${name}: ${t('ui.projects.atRisk', {}, 'At risk')}` });
316
+ }
317
+ }
318
+
319
+ if (alerts.length === 0) {
320
+ container.innerHTML = `<p class="text-muted" style="font-size:var(--text-xs);padding:var(--space-2) 0">${t('ui.projects.allClear', {}, 'All projects on track')}</p>`;
321
+ return;
322
+ }
323
+
324
+ container.innerHTML = alerts.slice(0, 5).map(a => `
325
+ <div class="attention-item">
326
+ <span class="attention-dot" style="background:var(--${a.severity})"></span>
327
+ <span style="font-size:var(--text-xs);color:var(--text-secondary)">${esc(a.text)}</span>
328
+ </div>
329
+ `).join('');
330
+ }
331
+
332
+ // ─────────────────────────────── HEALTH COMPUTATION ──────────────────────────
333
+
334
+ function _computeHealth(metrics, project) {
335
+ if (!metrics) return { status: 'unknown', label: '—', color: 'var(--text-muted)' };
336
+
337
+ const pct = metrics.totals?.all ? (metrics.totals.completed / metrics.totals.all) : 0;
338
+ const deadline = _getProjectDeadline(metrics, project);
339
+
340
+ if (deadline) {
341
+ const now = Date.now();
342
+ const deadlineTime = new Date(deadline).getTime();
343
+ if (deadlineTime < now && pct < 1) {
344
+ return { status: 'delayed', label: t('ui.projects.health.delayed', {}, 'Delayed'), color: 'var(--danger)' };
345
+ }
346
+ const daysLeft = Math.ceil((deadlineTime - now) / 86400000);
347
+ if (daysLeft <= 7 && pct < 0.8) {
348
+ return { status: 'at-risk', label: t('ui.projects.health.atRisk', {}, 'At risk'), color: 'var(--warning)' };
349
+ }
350
+ }
351
+
352
+ if (metrics.totals?.blocked > 0 && metrics.totals.blocked >= (metrics.totals.inProgress || 1)) {
353
+ return { status: 'at-risk', label: t('ui.projects.health.atRisk', {}, 'At risk'), color: 'var(--warning)' };
354
+ }
355
+
356
+ return { status: 'on-track', label: t('ui.projects.health.onTrack', {}, 'On track'), color: 'var(--success)' };
357
+ }
358
+
359
+ function _getProjectDeadline(metrics, project) {
360
+ if (!metrics?.milestones?.length) return null;
361
+ const sorted = [...metrics.milestones].sort((a, b) => new Date(b.date) - new Date(a.date));
362
+ return sorted[0]?.date || null;
363
+ }
364
+
365
+ function _getDeadlineInfo(metrics, project) {
366
+ const deadline = _getProjectDeadline(metrics, project);
367
+ if (!deadline) return null;
368
+ const d = new Date(deadline);
369
+ const now = Date.now();
370
+ const diff = Math.ceil((d.getTime() - now) / 86400000);
371
+ return { date: deadline, daysLeft: diff };
372
+ }
373
+
374
+ function _getPriority(metrics) {
375
+ if (!metrics?.totals) return null;
376
+ const { blocked, inProgress, all, completed } = metrics.totals;
377
+ const pct = all ? completed / all : 0;
378
+ if (blocked > 2 || pct < 0.3) return { label: t('ui.projects.priority.high', {}, 'High'), color: 'var(--danger)' };
379
+ if (blocked > 0 || pct < 0.6) return { label: t('ui.projects.priority.medium', {}, 'Medium'), color: 'var(--warning)' };
380
+ return { label: t('ui.projects.priority.low', {}, 'Low'), color: 'var(--success)' };
381
+ }
382
+
383
+ // ─────────────────────────────── CARD RENDER ─────────────────────────────────
384
+
385
+ function _renderProjectCard(project, isCurrent) {
386
+ const cached = _metricsCache.get(project.id);
387
+
388
+ return `
389
+ <article class="glass-card project-card ${isCurrent ? 'is-current project-card--active' : ''} ${!project.available ? 'is-unavailable' : ''}" data-project-id="${esc(project.id)}" aria-label="${esc(project.name)}" ${isCurrent ? 'style="border-color: var(--accent); border-width: 2px"' : ''}>
390
+ <div class="project-card-header">
391
+ <div class="project-card-info">
392
+ <div class="project-card-name-row">
393
+ <h3 class="project-card-name">${esc(project.name)}</h3>
394
+ <button class="project-info-btn" type="button" data-tooltip-trigger data-project-id="${esc(project.id)}" aria-label="${t('ui.projects.moreInfo', {}, 'More info')}">
395
+ ${icon('info', 14)}
396
+ </button>
397
+ <div class="project-info-tooltip is-hidden" data-tooltip-for="${esc(project.id)}">
398
+ <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.path', {}, 'Path')}:</span> <span class="tooltip-value">${esc(project.root || project.workspaceRoot || '—')}</span></div>
399
+ <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.registered', {}, 'Registered')}:</span> <span class="tooltip-value">${project.registeredAt ? formatDate(project.registeredAt, 'date') : '—'}</span></div>
400
+ <div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.lastSeen', {}, 'Last seen')}:</span> <span class="tooltip-value">${project.lastSeenAt ? formatDate(project.lastSeenAt, 'date') : '—'}</span></div>
401
+ ${cached ? `<div class="tooltip-row"><span class="tooltip-label">${t('ui.projects.deadline', {}, 'Deadline')}:</span> <span class="tooltip-value">${_getDeadlineInfo(cached, project) ? formatDate(_getDeadlineInfo(cached, project).date, 'date') : '—'}</span></div>` : ''}
402
+ </div>
403
+ </div>
404
+ </div>
405
+ <div class="project-card-badges">
406
+ ${isCurrent ? `<span class="badge badge-accent">${t('ui.projects.current', {}, 'Current')}</span>` : ''}
407
+ <span class="badge badge-${project.available ? 'success' : 'warning'}">
408
+ <span class="repo-badge-dot" style="width:6px;height:6px;border-radius:50%;background:${project.available ? 'var(--success)' : 'var(--warning)'};flex-shrink:0"></span>
409
+ ${project.available ? t('ui.projects.available', {}, 'Available') : t('ui.projects.unavailable', {}, 'Unavailable')}
410
+ </span>
411
+ ${project.layout ? `<span class="badge badge-muted">${esc(project.layout)}</span>` : ''}
412
+ </div>
413
+ </div>
414
+
415
+ ${project.available && cached ? _renderCardIndicators(cached, project) : ''}
416
+
417
+ ${project.available ? `
418
+ <div class="project-card-metrics" id="metrics-${esc(project.id)}">
419
+ ${cached ? _renderMetrics(cached) : _renderMetricsSkeleton()}
420
+ </div>
421
+
422
+ <div class="project-card-progress" id="progress-${esc(project.id)}">
423
+ ${cached ? _renderProgressBar(cached) : '<div class="skeleton skeleton-text" style="height:8px;width:100%"></div>'}
424
+ </div>
425
+ ` : `
426
+ <div class="project-card-metrics is-disabled">
427
+ <p class="text-muted" style="font-size:var(--text-xs);padding:var(--space-4) 0">${t('ui.projects.unavailableDesc', {}, 'Project directory not accessible.')}</p>
428
+ </div>
429
+ `}
430
+
431
+ <div class="project-card-actions">
432
+ ${project.available && !isCurrent ? `
433
+ <button class="btn btn-primary btn-sm" type="button" data-action="open" data-project="${esc(project.id)}" aria-label="${t('ui.projects.open', {}, 'Open project')}">
434
+ ${icon('externalLink', 14)} ${t('ui.projects.open', {}, 'Open')}
435
+ </button>
436
+ ` : ''}
437
+ ${project.available ? `
438
+ <button class="btn btn-ghost btn-sm" type="button" data-action="refresh" data-project="${esc(project.id)}" aria-label="${t('ui.projects.refresh', {}, 'Refresh')}">
439
+ ${icon('refresh', 14)} ${t('ui.projects.refresh', {}, 'Refresh')}
440
+ </button>
441
+ <button class="btn btn-ghost btn-sm" type="button" data-action="sync" data-project="${esc(project.id)}" aria-label="${t('ui.projects.syncDocs', {}, 'Sync docs')}">
442
+ ${icon('sync', 14)} ${t('ui.projects.syncDocs', {}, 'Sync')}
443
+ </button>
444
+ ` : ''}
445
+ <button class="btn btn-ghost btn-sm" style="color:var(--danger)" type="button" data-action="remove" data-project="${esc(project.id)}" aria-label="${t('ui.projects.remove', {}, 'Remove')}">
446
+ ${icon('trash', 14)} ${t('ui.projects.remove', {}, 'Remove')}
447
+ </button>
448
+ </div>
449
+ </article>
450
+ `;
451
+ }
452
+
453
+ function _renderCardIndicators(metrics, project) {
454
+ const health = _computeHealth(metrics, project);
455
+ const deadline = _getDeadlineInfo(metrics, project);
456
+ const priority = _getPriority(metrics);
457
+
458
+ return `
459
+ <div class="project-card-indicators">
460
+ <div class="project-indicator">
461
+ <span class="indicator-dot" style="background:${health.color}"></span>
462
+ <span class="indicator-label">${health.label}</span>
463
+ </div>
464
+ ${deadline ? `
465
+ <div class="project-indicator">
466
+ ${icon('calendar', 12)}
467
+ <span class="indicator-label ${deadline.daysLeft < 0 ? 'text-danger' : deadline.daysLeft <= 7 ? 'text-warning' : ''}">${
468
+ deadline.daysLeft < 0
469
+ ? t('ui.projects.overdue', { days: Math.abs(deadline.daysLeft) }, `${Math.abs(deadline.daysLeft)}d overdue`)
470
+ : deadline.daysLeft === 0
471
+ ? t('ui.projects.dueToday', {}, 'Due today')
472
+ : t('ui.projects.daysLeft', { days: deadline.daysLeft }, `${deadline.daysLeft}d left`)
473
+ }</span>
474
+ </div>
475
+ ` : ''}
476
+ ${priority ? `
477
+ <div class="project-indicator">
478
+ <span class="indicator-label" style="color:${priority.color};font-weight:700">${priority.label}</span>
479
+ </div>
480
+ ` : ''}
481
+ </div>
482
+ `;
483
+ }
484
+
485
+ function _renderMetrics(metrics) {
486
+ const { totals, activePhase } = metrics;
487
+ const completionPct = totals.all ? Math.round((totals.completed / totals.all) * 100) : 0;
488
+
489
+ return `
490
+ <div class="project-metrics-grid">
491
+ <div class="glass-card project-metric">
492
+ <span class="project-metric-value">${totals.all}</span>
493
+ <span class="project-metric-label">${t('ui.projects.metric.tasks', {}, 'Tasks')}</span>
494
+ </div>
495
+ <div class="glass-card project-metric">
496
+ <span class="project-metric-value" style="color:var(--success)">${totals.completed}</span>
497
+ <span class="project-metric-label">${t('ui.projects.metric.done', {}, 'Done')}</span>
498
+ </div>
499
+ <div class="glass-card project-metric">
500
+ <span class="project-metric-value" style="color:${totals.blocked > 0 ? 'var(--danger)' : 'var(--text-primary)'}">${totals.blocked}</span>
501
+ <span class="project-metric-label">${t('ui.projects.metric.blocked', {}, 'Blocked')}</span>
502
+ </div>
503
+ <div class="glass-card project-metric">
504
+ <span class="project-metric-value" style="color:var(--accent)">${_phaseLabel(activePhase?.id)}</span>
505
+ <span class="project-metric-label">${t('ui.projects.metric.phase', {}, 'Phase')}</span>
506
+ </div>
507
+ <div class="glass-card project-metric">
508
+ <span class="project-metric-value">${completionPct}%</span>
509
+ <span class="project-metric-label">${t('ui.projects.metric.progress', {}, 'Progress')}</span>
510
+ </div>
511
+ </div>
512
+ `;
513
+ }
514
+
515
+ function _renderProgressBar(metrics) {
516
+ const pct = metrics.totals.all ? Math.round((metrics.totals.completed / metrics.totals.all) * 100) : 0;
517
+ return `
518
+ <div class="project-progress-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="Completion: ${pct}%">
519
+ <div class="project-progress-fill" style="width:${pct}%"></div>
520
+ </div>
521
+ `;
522
+ }
523
+
524
+ function _renderMetricsSkeleton() {
525
+ return `
526
+ <div class="project-metrics-grid">
527
+ ${Array.from({ length: 5 }, () => `
528
+ <div class="glass-card project-metric">
529
+ <div class="skeleton" style="width:32px;height:20px;margin:0 auto var(--space-1)"></div>
530
+ <div class="skeleton skeleton-text" style="width:40px;height:10px;margin:0 auto"></div>
531
+ </div>
532
+ `).join('')}
533
+ </div>
534
+ `;
535
+ }
536
+
537
+ // ─────────────────────────────── TOOLTIP ──────────────────────────────────────
538
+
539
+ function _handleTooltipShow(e) {
540
+ const trigger = e.target.closest('[data-tooltip-trigger]');
541
+ if (!trigger) return;
542
+ const projectId = trigger.dataset.projectId;
543
+ const tooltip = document.querySelector(`[data-tooltip-for="${projectId}"]`);
544
+ if (tooltip) tooltip.classList.remove('is-hidden');
545
+ }
546
+
547
+ function _handleTooltipHide(e) {
548
+ const trigger = e.target.closest('[data-tooltip-trigger]');
549
+ if (!trigger) return;
550
+ const projectId = trigger.dataset.projectId;
551
+ const tooltip = document.querySelector(`[data-tooltip-for="${projectId}"]`);
552
+ if (tooltip) tooltip.classList.add('is-hidden');
553
+ }
554
+
555
+ // ─────────────────────────────── METRICS LOADING ─────────────────────────────
556
+
557
+ async function _loadAllMetrics() {
558
+ const projects = (state.get('projects') || []).filter(p => p.available);
559
+
560
+ await Promise.allSettled(
561
+ projects.map(async (p) => {
562
+ try {
563
+ const payload = await api.getProjectState(p.id);
564
+ const metrics = {
565
+ totals: payload.derived?.totals || { all: 0, completed: 0, blocked: 0, pending: 0, inProgress: 0, inReview: 0, cancelled: 0 },
566
+ activePhase: payload.derived?.activePhase || null,
567
+ runtime: payload.runtime || null,
568
+ opera: payload.opera || null,
569
+ milestones: payload.control?.milestones || [],
570
+ tasks: payload.control?.tasks || [],
571
+ };
572
+ _metricsCache.set(p.id, metrics);
573
+ _updateCardMetrics(p.id, metrics);
574
+ } catch (err) {
575
+ console.warn(`[projects] Could not load metrics for ${p.name}:`, err.message);
576
+ }
577
+ })
578
+ );
579
+
580
+ // After all metrics loaded, update portfolio analytics
581
+ _updatePortfolioAnalytics();
582
+ }
583
+
584
+ function _updateCardMetrics(projectId, metrics) {
585
+ const metricsEl = document.getElementById(`metrics-${projectId}`);
586
+ if (metricsEl) metricsEl.innerHTML = _renderMetrics(metrics);
587
+
588
+ const progressEl = document.getElementById(`progress-${projectId}`);
589
+ if (progressEl) progressEl.innerHTML = _renderProgressBar(metrics);
590
+
591
+ // Update indicators
592
+ const card = document.querySelector(`[data-project-id="${projectId}"]`);
593
+ if (card) {
594
+ const existingIndicators = card.querySelector('.project-card-indicators');
595
+ const project = (state.get('projects') || []).find(p => p.id === projectId);
596
+ const indicatorsHtml = _renderCardIndicators(metrics, project);
597
+ if (existingIndicators) {
598
+ existingIndicators.outerHTML = indicatorsHtml;
599
+ } else {
600
+ const header = card.querySelector('.project-card-header');
601
+ if (header) header.insertAdjacentHTML('afterend', indicatorsHtml);
602
+ }
603
+ }
604
+ }
605
+
606
+ // ─────────────────────────────── ACTIONS ─────────────────────────────────────
607
+
608
+ function _handleCardAction(e) {
609
+ const btn = e.target.closest('[data-action]');
610
+ if (!btn) return;
611
+ const action = btn.dataset.action;
612
+ const projectId = btn.dataset.project;
613
+ switch (action) {
614
+ case 'open': _openProject(projectId); break;
615
+ case 'refresh': _refreshProject(projectId); break;
616
+ case 'sync': _syncProject(projectId); break;
617
+ case 'remove': _removeProject(projectId); break;
618
+ }
619
+ }
620
+
621
+ async function _openProject(projectId) {
622
+ state.update('currentProjectId', projectId);
623
+ localStorage.setItem('ops-dashboard-project', projectId);
624
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
625
+ router.navigate('dashboard');
626
+ flash(t('ui.projects.opened', {}, 'Project opened'), 'success');
627
+ }
628
+
629
+ async function _refreshProject(projectId) {
630
+ const btn = document.querySelector(`[data-action="refresh"][data-project="${projectId}"]`);
631
+ if (btn) btn.disabled = true;
632
+ try {
633
+ await api.getProjectState(projectId);
634
+ _metricsCache.delete(projectId);
635
+ await _loadAllMetrics();
636
+ flash(t('ui.projects.refreshed', {}, 'Project refreshed'), 'success');
637
+ } catch (err) {
638
+ flash(`Error: ${err.message}`, 'error');
639
+ } finally {
640
+ if (btn) btn.disabled = false;
641
+ }
642
+ }
643
+
644
+ async function _syncProject(projectId) {
645
+ try {
646
+ await api.call('/api/sync', {
647
+ method: 'POST',
648
+ body: JSON.stringify({ projectId }),
649
+ projectAware: false,
650
+ });
651
+ flash(t('ui.projects.synced', {}, 'Docs synced'), 'success');
652
+ } catch (err) {
653
+ flash(`Sync error: ${err.message}`, 'error');
654
+ }
655
+ }
656
+
657
+ async function _removeProject(projectId) {
658
+ const project = (state.get('projects') || []).find(p => p.id === projectId);
659
+ if (!project) return;
660
+ const confirmed = confirm(
661
+ t('ui.projects.removeConfirm', { name: project.name },
662
+ `Remove "${project.name}" from the registry? This will NOT delete any files.`)
663
+ );
664
+ if (!confirmed) return;
665
+ try {
666
+ const result = await api.removeProject(projectId);
667
+ state.update('projects', result.projects || []);
668
+ _metricsCache.delete(projectId);
669
+ if (state.get('currentProjectId') === projectId) {
670
+ const first = (result.projects || []).find(p => p.available);
671
+ state.update('currentProjectId', first?.id || null);
672
+ if (first) localStorage.setItem('ops-dashboard-project', first.id);
673
+ }
674
+ router.refresh();
675
+ flash(t('ui.projects.removed', {}, 'Project removed from registry'), 'success');
676
+ } catch (err) {
677
+ flash(`Error: ${err.message}`, 'error');
678
+ }
679
+ }
680
+
681
+ async function _purgeUnavailable() {
682
+ const projects = state.get('projects') || [];
683
+ const count = projects.filter(p => !p.available).length;
684
+ if (!count) return;
685
+ const confirmed = confirm(
686
+ `Remove ${count} unavailable project${count !== 1 ? 's' : ''} from the registry? This will NOT delete any files.`
687
+ );
688
+ if (!confirmed) return;
689
+ const btn = document.getElementById('projects-purge-unavailable');
690
+ if (btn) btn.disabled = true;
691
+ try {
692
+ const result = await api.purgeUnavailableProjects();
693
+ state.update('projects', result.projects || []);
694
+ _metricsCache.clear();
695
+ router.refresh();
696
+ flash(`Removed ${result.removed} unavailable project${result.removed !== 1 ? 's' : ''}`, 'success');
697
+ } catch (err) {
698
+ flash(`Error: ${err.message}`, 'error');
699
+ if (btn) btn.disabled = false;
700
+ }
701
+ }
702
+
703
+ async function _refreshAll() {
704
+ _metricsCache.clear();
705
+ try {
706
+ const result = await api.getProjects();
707
+ state.update('projects', result.projects || []);
708
+ router.refresh();
709
+ } catch (err) {
710
+ flash(`Error: ${err.message}`, 'error');
711
+ }
712
+ }
713
+
714
+ // ─────────────────────────────── MODAL ───────────────────────────────────────
715
+
716
+ function _showRegisterModal() {
717
+ const modal = document.getElementById('register-modal');
718
+ if (modal) {
719
+ modal.classList.remove('is-hidden');
720
+ document.getElementById('register-path')?.focus();
721
+ }
722
+ }
723
+
724
+ function _hideRegisterModal() {
725
+ const modal = document.getElementById('register-modal');
726
+ if (modal) modal.classList.add('is-hidden');
727
+ }
728
+
729
+ async function _confirmRegister() {
730
+ const input = document.getElementById('register-path');
731
+ const path = input?.value?.trim();
732
+ if (!path) {
733
+ flash(t('ui.projects.pathRequired', {}, 'Please enter a project path'), 'warning');
734
+ return;
735
+ }
736
+ try {
737
+ const result = await api.registerProject(path);
738
+ state.update('projects', result.projects || []);
739
+ _hideRegisterModal();
740
+ router.refresh();
741
+ flash(t('ui.projects.registered', {}, 'Project registered'), 'success');
742
+ } catch (err) {
743
+ flash(`Error: ${err.message}`, 'error');
744
+ }
745
+ }