trackops 2.0.3 → 2.0.5
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.
- package/LICENSE +21 -21
- package/README.md +695 -402
- package/bin/trackops.js +116 -116
- package/lib/config.js +326 -326
- package/lib/control.js +208 -208
- package/lib/env.js +244 -244
- package/lib/init.js +325 -325
- package/lib/locale.js +24 -0
- package/lib/opera-bootstrap.js +941 -874
- package/lib/opera.js +494 -477
- package/lib/preferences.js +74 -74
- package/lib/registry.js +214 -196
- package/lib/release.js +56 -56
- package/lib/runtime-state.js +144 -144
- package/lib/server.js +312 -207
- package/lib/skills.js +74 -57
- package/lib/workspace.js +260 -260
- package/locales/en.json +192 -166
- package/locales/es.json +192 -166
- package/package.json +61 -58
- package/scripts/postinstall-locale.js +21 -21
- package/scripts/skills-marketplace-smoke.js +124 -124
- package/scripts/smoke-tests.js +558 -554
- package/scripts/sync-skill-version.js +21 -21
- package/scripts/validate-skill.js +103 -103
- package/skills/trackops/SKILL.md +126 -122
- package/skills/trackops/agents/openai.yaml +7 -7
- package/skills/trackops/locales/en/SKILL.md +126 -122
- package/skills/trackops/locales/en/references/activation.md +94 -75
- package/skills/trackops/locales/en/references/troubleshooting.md +73 -55
- package/skills/trackops/locales/en/references/workflow.md +55 -32
- package/skills/trackops/references/activation.md +94 -75
- package/skills/trackops/references/troubleshooting.md +73 -55
- package/skills/trackops/references/workflow.md +55 -32
- package/skills/trackops/skill.json +29 -29
- package/templates/hooks/post-checkout +2 -2
- package/templates/hooks/post-commit +2 -2
- package/templates/hooks/post-merge +2 -2
- package/templates/opera/agent.md +28 -27
- package/templates/opera/architecture/dependency-graph.md +24 -24
- package/templates/opera/architecture/runtime-automation.md +24 -24
- package/templates/opera/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/agent.md +22 -21
- package/templates/opera/en/architecture/dependency-graph.md +24 -24
- package/templates/opera/en/architecture/runtime-automation.md +24 -24
- package/templates/opera/en/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/reviews/delivery-audit.md +18 -18
- package/templates/opera/en/reviews/integration-audit.md +18 -18
- package/templates/opera/en/router.md +24 -19
- package/templates/opera/references/autonomy-and-recovery.md +117 -117
- package/templates/opera/references/opera-cycle.md +193 -193
- package/templates/opera/registry.md +28 -28
- package/templates/opera/reviews/delivery-audit.md +18 -18
- package/templates/opera/reviews/integration-audit.md +18 -18
- package/templates/opera/router.md +54 -49
- package/templates/skills/changelog-updater/SKILL.md +69 -69
- package/templates/skills/commiter/SKILL.md +99 -99
- package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
- package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
- package/templates/skills/opera-policy-guard/SKILL.md +26 -26
- package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
- package/templates/skills/opera-skill/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
- package/templates/skills/opera-skill/references/phase-dod.md +138 -0
- package/templates/skills/project-starter-skill/SKILL.md +150 -131
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
- package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
- package/ui/css/base.css +284 -266
- package/ui/css/charts.css +425 -327
- package/ui/css/components.css +1107 -570
- package/ui/css/onboarding.css +133 -0
- package/ui/css/panels.css +345 -406
- package/ui/css/terminal.css +125 -0
- package/ui/css/timeline.css +58 -0
- package/ui/css/tokens.css +284 -227
- package/ui/favicon.svg +5 -5
- package/ui/index.html +99 -96
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/console-logger.js +172 -172
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +129 -104
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +142 -125
- package/ui/js/theme.js +100 -100
- package/ui/js/time-tracker.js +248 -248
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/flash.js +47 -47
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +153 -203
- package/ui/js/views/sidebar.js +37 -31
- package/ui/js/views/tasks.js +218 -101
- package/ui/js/views/timeline.js +265 -0
- package/ui/js/views/topbar.js +94 -107
- package/ui/app.js +0 -950
- package/ui/js/views/insights.js +0 -340
- package/ui/js/views/overview.js +0 -369
- package/ui/styles.css +0 -688
|
@@ -0,0 +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">×</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
|
+
}
|