requ-mcp 0.2.0 → 0.5.0
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/README.md +64 -3
- package/dist/coverage.d.ts +28 -4
- package/dist/coverage.js +48 -6
- package/dist/coverage.js.map +1 -1
- package/dist/export-import.d.ts +10 -0
- package/dist/export-import.js +127 -0
- package/dist/export-import.js.map +1 -0
- package/dist/index.js +663 -58
- package/dist/index.js.map +1 -1
- package/dist/public/app.js +914 -0
- package/dist/public/index.html +1418 -0
- package/dist/public/style.css +458 -0
- package/dist/schema.d.ts +666 -28
- package/dist/schema.js +90 -29
- package/dist/schema.js.map +1 -1
- package/dist/sqlite-store.d.ts +43 -0
- package/dist/sqlite-store.js +210 -0
- package/dist/sqlite-store.js.map +1 -0
- package/dist/storage.d.ts +16 -4
- package/dist/storage.js +53 -19
- package/dist/storage.js.map +1 -1
- package/dist/web-api.d.ts +9 -0
- package/dist/web-api.js +789 -0
- package/dist/web-api.js.map +1 -0
- package/package.json +9 -5
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/* requ — Alpine.js dashboard component
|
|
2
|
+
* Matches index.html exactly. All method names / property names are canonical here.
|
|
3
|
+
* global Chart, Alpine
|
|
4
|
+
*/
|
|
5
|
+
document.addEventListener('alpine:init', function () {
|
|
6
|
+
Alpine.data('requApp', function () {
|
|
7
|
+
return {
|
|
8
|
+
|
|
9
|
+
// ── Navigation ──────────────────────────────────────────────────────────
|
|
10
|
+
tab: 'overview',
|
|
11
|
+
|
|
12
|
+
// ── Global state ────────────────────────────────────────────────────────
|
|
13
|
+
notInitialized: false,
|
|
14
|
+
|
|
15
|
+
/** Granular loading flags so each section shows its own skeleton. */
|
|
16
|
+
loading: {
|
|
17
|
+
config: false, summary: false, requirements: false,
|
|
18
|
+
stories: false, components: false, phases: false,
|
|
19
|
+
vcs: false, coverage: false, trend: false, gaps: false,
|
|
20
|
+
global: false,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
// ── Data ────────────────────────────────────────────────────────────────
|
|
24
|
+
config: null,
|
|
25
|
+
summary: null,
|
|
26
|
+
requirements: [],
|
|
27
|
+
stories: [],
|
|
28
|
+
components: [],
|
|
29
|
+
phases: [],
|
|
30
|
+
vcsRefs: [],
|
|
31
|
+
coverage: null,
|
|
32
|
+
trend: null,
|
|
33
|
+
gaps: null,
|
|
34
|
+
|
|
35
|
+
// ── Requirement filters ──────────────────────────────────────────────────
|
|
36
|
+
reqSearch: '',
|
|
37
|
+
reqStatusFilter: 'all',
|
|
38
|
+
reqPriorityFilter: 'all',
|
|
39
|
+
reqComponentFilter: 'all',
|
|
40
|
+
reqPhaseFilter: 'all',
|
|
41
|
+
reqExpanded: null,
|
|
42
|
+
reqSortBy: 'id',
|
|
43
|
+
|
|
44
|
+
// ── Story filters ────────────────────────────────────────────────────────
|
|
45
|
+
storySearch: '',
|
|
46
|
+
storyStatusFilter: 'all',
|
|
47
|
+
storyPhaseFilter: 'all',
|
|
48
|
+
storyExpanded: null,
|
|
49
|
+
|
|
50
|
+
// ── VCS filters ──────────────────────────────────────────────────────────
|
|
51
|
+
vcsKindFilter: 'all',
|
|
52
|
+
vcsStateFilter: 'all',
|
|
53
|
+
|
|
54
|
+
// ── Coverage controls ────────────────────────────────────────────────────
|
|
55
|
+
coveragePhase: null,
|
|
56
|
+
coverageMode: 'cumulative',
|
|
57
|
+
showStoriesDetail: false,
|
|
58
|
+
|
|
59
|
+
// ── Charts ───────────────────────────────────────────────────────────────
|
|
60
|
+
_trendChart: null,
|
|
61
|
+
_donutChart: null,
|
|
62
|
+
|
|
63
|
+
// ── SSE handle ───────────────────────────────────────────────────────────
|
|
64
|
+
_sse: null,
|
|
65
|
+
|
|
66
|
+
// ── Multi-project ─────────────────────────────────────────────────────────────
|
|
67
|
+
projects: [],
|
|
68
|
+
activeProject: null,
|
|
69
|
+
globalSummary: [],
|
|
70
|
+
|
|
71
|
+
// ── Export / Import ───────────────────────────────────────────────────────────
|
|
72
|
+
importDialogOpen: false,
|
|
73
|
+
importResult: null,
|
|
74
|
+
importing: false,
|
|
75
|
+
|
|
76
|
+
// ── Setup form ─────────────────────────────────────────────────────────────
|
|
77
|
+
setupName: '',
|
|
78
|
+
setupKey: '',
|
|
79
|
+
setupBrief: '',
|
|
80
|
+
setupPhase: '',
|
|
81
|
+
setupSubmitting: false,
|
|
82
|
+
setupError: null,
|
|
83
|
+
|
|
84
|
+
// ── Brief inline edit ──────────────────────────────────────────────────────
|
|
85
|
+
briefEditing: false,
|
|
86
|
+
briefDraft: '',
|
|
87
|
+
briefSaving: false,
|
|
88
|
+
briefError: null,
|
|
89
|
+
briefExpanded: false,
|
|
90
|
+
briefOverflows: false,
|
|
91
|
+
|
|
92
|
+
// ── Server version ─────────────────────────────────────────────────────────
|
|
93
|
+
appVersion: '',
|
|
94
|
+
|
|
95
|
+
// =========================================================================
|
|
96
|
+
// Lifecycle
|
|
97
|
+
// =========================================================================
|
|
98
|
+
|
|
99
|
+
async init() {
|
|
100
|
+
var vd = await this._fetch('/api/version');
|
|
101
|
+
if (vd && vd.version) this.appVersion = vd.version;
|
|
102
|
+
await this.loadProjects();
|
|
103
|
+
if (this.projects.length > 1) { this.tab = 'global'; }
|
|
104
|
+
await this.loadConfig();
|
|
105
|
+
await this.loadSummary();
|
|
106
|
+
this.setupSSE();
|
|
107
|
+
var loaders = [
|
|
108
|
+
this.loadRequirements(),
|
|
109
|
+
this.loadStories(),
|
|
110
|
+
this.loadComponents(),
|
|
111
|
+
this.loadPhases(),
|
|
112
|
+
this.loadVcsRefs(),
|
|
113
|
+
this.loadCoverage(),
|
|
114
|
+
this.loadTrend(),
|
|
115
|
+
this.loadGaps(),
|
|
116
|
+
];
|
|
117
|
+
if (this.projects.length > 1) loaders.push(this.loadGlobalSummary());
|
|
118
|
+
await Promise.all(loaders);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// =========================================================================
|
|
122
|
+
// API helpers
|
|
123
|
+
// =========================================================================
|
|
124
|
+
|
|
125
|
+
async _fetch(url) {
|
|
126
|
+
try {
|
|
127
|
+
var res = await window.fetch(url);
|
|
128
|
+
if (res.status === 503) {
|
|
129
|
+
var body = await res.json().catch(function () { return {}; });
|
|
130
|
+
if (body && body.code === 'NOT_INITIALIZED') {
|
|
131
|
+
// Only set to true here; reset happens only when config loads successfully
|
|
132
|
+
// to avoid a race where concurrent loaders write conflicting values.
|
|
133
|
+
this.notInitialized = true;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!res.ok) return null;
|
|
138
|
+
return await res.json();
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// =========================================================================
|
|
145
|
+
// Loaders
|
|
146
|
+
// =========================================================================
|
|
147
|
+
|
|
148
|
+
async loadProjects() {
|
|
149
|
+
// /api/projects never requires ?project= — it lists all loaded projects.
|
|
150
|
+
var d = await this._fetch('/api/projects');
|
|
151
|
+
if (d && Array.isArray(d)) {
|
|
152
|
+
this.projects = d;
|
|
153
|
+
if (d.length > 0 && !this.activeProject) {
|
|
154
|
+
this.activeProject = d[0];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async loadGlobalSummary() {
|
|
160
|
+
this.loading.global = true;
|
|
161
|
+
try {
|
|
162
|
+
var d = await this._fetch('/api/global');
|
|
163
|
+
if (d && Array.isArray(d)) this.globalSummary = d;
|
|
164
|
+
} finally {
|
|
165
|
+
this.loading.global = false;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async switchProject(slug) {
|
|
170
|
+
var self = this;
|
|
171
|
+
var found = this.projects.find(function (p) { return p.slug === slug; });
|
|
172
|
+
if (!found || found === this.activeProject) return;
|
|
173
|
+
this.activeProject = found;
|
|
174
|
+
// Reconnect SSE for the new project.
|
|
175
|
+
if (this._sse) { this._sse.close(); this._sse = null; }
|
|
176
|
+
this.setupSSE();
|
|
177
|
+
// Reload all data for the new project.
|
|
178
|
+
await Promise.all([
|
|
179
|
+
self.loadConfig(),
|
|
180
|
+
self.loadSummary(),
|
|
181
|
+
self.loadRequirements(),
|
|
182
|
+
self.loadStories(),
|
|
183
|
+
self.loadComponents(),
|
|
184
|
+
self.loadPhases(),
|
|
185
|
+
self.loadVcsRefs(),
|
|
186
|
+
self.loadCoverage(),
|
|
187
|
+
self.loadTrend(),
|
|
188
|
+
self.loadGaps(),
|
|
189
|
+
]);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async loadConfig() {
|
|
193
|
+
this.loading.config = true;
|
|
194
|
+
var d = await this._fetch(this.apiUrl('/api/config'));
|
|
195
|
+
if (d) {
|
|
196
|
+
this.config = d;
|
|
197
|
+
// Single authoritative reset: if config loads, project is initialized.
|
|
198
|
+
this.notInitialized = false;
|
|
199
|
+
}
|
|
200
|
+
this.loading.config = false;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async loadSummary() {
|
|
204
|
+
this.loading.summary = true;
|
|
205
|
+
var d = await this._fetch(this.apiUrl('/api/summary'));
|
|
206
|
+
if (d) {
|
|
207
|
+
this.summary = d;
|
|
208
|
+
if (!this.coveragePhase && d.activePhase) this.coveragePhase = d.activePhase;
|
|
209
|
+
}
|
|
210
|
+
this.loading.summary = false;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async loadRequirements() {
|
|
214
|
+
this.loading.requirements = true;
|
|
215
|
+
var d = await this._fetch(this.apiUrl('/api/requirements'));
|
|
216
|
+
if (d) this.requirements = d;
|
|
217
|
+
this.loading.requirements = false;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async loadStories() {
|
|
221
|
+
this.loading.stories = true;
|
|
222
|
+
var d = await this._fetch(this.apiUrl('/api/stories'));
|
|
223
|
+
if (d) this.stories = d;
|
|
224
|
+
this.loading.stories = false;
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async loadComponents() {
|
|
228
|
+
this.loading.components = true;
|
|
229
|
+
var d = await this._fetch(this.apiUrl('/api/components'));
|
|
230
|
+
if (d) this.components = d;
|
|
231
|
+
this.loading.components = false;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async loadPhases() {
|
|
235
|
+
this.loading.phases = true;
|
|
236
|
+
var d = await this._fetch(this.apiUrl('/api/phases'));
|
|
237
|
+
if (d) this.phases = d;
|
|
238
|
+
this.loading.phases = false;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async loadVcsRefs() {
|
|
242
|
+
this.loading.vcs = true;
|
|
243
|
+
var d = await this._fetch(this.apiUrl('/api/vcs'));
|
|
244
|
+
if (d) this.vcsRefs = d;
|
|
245
|
+
this.loading.vcs = false;
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async loadCoverage() {
|
|
249
|
+
this.loading.coverage = true;
|
|
250
|
+
var phase = this.coveragePhase ? ('&phase=' + encodeURIComponent(this.coveragePhase)) : '';
|
|
251
|
+
var d = await this._fetch(this.apiUrl('/api/coverage?mode=' + this.coverageMode + phase));
|
|
252
|
+
if (d) this.coverage = d;
|
|
253
|
+
this.loading.coverage = false;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async loadTrend() {
|
|
257
|
+
this.loading.trend = true;
|
|
258
|
+
var d = await this._fetch(this.apiUrl('/api/coverage/trend'));
|
|
259
|
+
if (d) this.trend = d;
|
|
260
|
+
this.loading.trend = false;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async loadGaps() {
|
|
264
|
+
this.loading.gaps = true;
|
|
265
|
+
var phase = this.coveragePhase ? ('&phase=' + encodeURIComponent(this.coveragePhase)) : '';
|
|
266
|
+
var d = await this._fetch(this.apiUrl('/api/coverage/gaps?mode=' + this.coverageMode + phase));
|
|
267
|
+
if (d) this.gaps = d;
|
|
268
|
+
this.loading.gaps = false;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async refreshCoverage() {
|
|
272
|
+
await Promise.all([this.loadCoverage(), this.loadGaps()]);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// =========================================================================
|
|
276
|
+
// SSE
|
|
277
|
+
// =========================================================================
|
|
278
|
+
|
|
279
|
+
setupSSE() {
|
|
280
|
+
if (this._sse) return;
|
|
281
|
+
var self = this;
|
|
282
|
+
try {
|
|
283
|
+
var es = new EventSource(this.apiUrl('/events'));
|
|
284
|
+
es.onmessage = function (e) {
|
|
285
|
+
try {
|
|
286
|
+
var d = JSON.parse(e.data);
|
|
287
|
+
if (d && typeof d === 'object') {
|
|
288
|
+
var prev = self.summary;
|
|
289
|
+
self.summary = d;
|
|
290
|
+
self.notInitialized = false;
|
|
291
|
+
if (self.tab === 'global') { self.loadGlobalSummary(); }
|
|
292
|
+
if (!prev || d.requirements !== prev.requirements) self.loadRequirements();
|
|
293
|
+
if (!prev || d.stories !== prev.stories) self.loadStories();
|
|
294
|
+
if (!prev || d.components !== prev.components) self.loadComponents();
|
|
295
|
+
if (!prev || d.phases !== prev.phases) self.loadPhases();
|
|
296
|
+
if (!prev || d.vcsRefs !== prev.vcsRefs) self.loadVcsRefs();
|
|
297
|
+
var coverageChanged = !prev ||
|
|
298
|
+
d.verifiedPct !== prev.verifiedPct ||
|
|
299
|
+
d.storyCoveragePct !== prev.storyCoveragePct ||
|
|
300
|
+
d.stories !== prev.stories ||
|
|
301
|
+
d.requirements !== prev.requirements;
|
|
302
|
+
if (coverageChanged) { self.loadCoverage(); self.loadTrend(); self.loadGaps(); }
|
|
303
|
+
}
|
|
304
|
+
} catch (_) {}
|
|
305
|
+
};
|
|
306
|
+
es.onerror = function () {};
|
|
307
|
+
this._sse = es;
|
|
308
|
+
} catch (_) {}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
// =========================================================================
|
|
312
|
+
// Tab navigation
|
|
313
|
+
// =========================================================================
|
|
314
|
+
|
|
315
|
+
navTo(id) {
|
|
316
|
+
this.tab = id;
|
|
317
|
+
if (id === 'global') { this.loadGlobalSummary(); }
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Keyboard arrow navigation for the tab list (ARIA tablist pattern).
|
|
322
|
+
* dir=1 → next, dir=-1 → prev, dir=-999 → first, dir=999 → last.
|
|
323
|
+
*/
|
|
324
|
+
shiftFocus(dir) {
|
|
325
|
+
var tabs = this.projects.length > 1
|
|
326
|
+
? ['global', 'overview', 'requirements', 'stories', 'coverage', 'components', 'vcs']
|
|
327
|
+
: ['overview', 'requirements', 'stories', 'coverage', 'components', 'vcs'];
|
|
328
|
+
var idx = tabs.indexOf(this.tab);
|
|
329
|
+
if (dir === -999) { idx = 0; }
|
|
330
|
+
else if (dir === 999) { idx = tabs.length - 1; }
|
|
331
|
+
else { idx = (idx + dir + tabs.length) % tabs.length; }
|
|
332
|
+
this.navTo(tabs[idx]);
|
|
333
|
+
var self = this;
|
|
334
|
+
this.$nextTick(function () {
|
|
335
|
+
var el = document.querySelector('[role="tab"][aria-selected="true"]');
|
|
336
|
+
if (el) el.focus();
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Returns the given API path with ?project=<slug> appended when
|
|
342
|
+
* multiple projects are loaded. Handles paths that already have a
|
|
343
|
+
* query string by using '&' instead of '?'.
|
|
344
|
+
*/
|
|
345
|
+
apiUrl: function (p) {
|
|
346
|
+
if (this.projects.length <= 1 || !this.activeProject) return p;
|
|
347
|
+
var sep = p.indexOf('?') === -1 ? '?' : '&';
|
|
348
|
+
return p + sep + 'project=' + this.activeProject.slug;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// =========================================================================
|
|
352
|
+
// Filtered list methods (called as functions in Alpine x-for / x-text)
|
|
353
|
+
// =========================================================================
|
|
354
|
+
|
|
355
|
+
filteredRequirements() {
|
|
356
|
+
var self = this;
|
|
357
|
+
var list = this.requirements.slice();
|
|
358
|
+
var q = this.reqSearch ? this.reqSearch.toLowerCase().trim() : '';
|
|
359
|
+
|
|
360
|
+
if (q) {
|
|
361
|
+
list = list.filter(function (r) {
|
|
362
|
+
return (
|
|
363
|
+
r.id.toLowerCase().indexOf(q) !== -1 ||
|
|
364
|
+
r.title.toLowerCase().indexOf(q) !== -1 ||
|
|
365
|
+
(r.tags || []).some(function (t) { return t.toLowerCase().indexOf(q) !== -1; })
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if (this.reqStatusFilter !== 'all') {
|
|
370
|
+
list = list.filter(function (r) { return r.status === self.reqStatusFilter; });
|
|
371
|
+
}
|
|
372
|
+
if (this.reqPriorityFilter !== 'all') {
|
|
373
|
+
list = list.filter(function (r) { return r.priority === self.reqPriorityFilter; });
|
|
374
|
+
}
|
|
375
|
+
if (this.reqComponentFilter !== 'all') {
|
|
376
|
+
list = list.filter(function (r) {
|
|
377
|
+
return (r.components || []).indexOf(self.reqComponentFilter) !== -1;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (this.reqPhaseFilter !== 'all') {
|
|
381
|
+
list = list.filter(function (r) {
|
|
382
|
+
return self.reqPhaseFilter === '(none)' ? !r.phase : r.phase === self.reqPhaseFilter;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Sort
|
|
387
|
+
var priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
388
|
+
var self2 = this;
|
|
389
|
+
list.sort(function (a, b) {
|
|
390
|
+
if (self2.reqSortBy === 'priority') {
|
|
391
|
+
return ((priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 9) -
|
|
392
|
+
(priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 9));
|
|
393
|
+
}
|
|
394
|
+
if (self2.reqSortBy === 'status') {
|
|
395
|
+
return self2._reqCoverageKey(a).localeCompare(self2._reqCoverageKey(b));
|
|
396
|
+
}
|
|
397
|
+
return a.id.localeCompare(b.id);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return list;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
filteredStories() {
|
|
404
|
+
var self = this;
|
|
405
|
+
return this.stories.filter(function (s) {
|
|
406
|
+
if (self.storyStatusFilter !== 'all' && s.status !== self.storyStatusFilter) return false;
|
|
407
|
+
if (self.storyPhaseFilter !== 'all') {
|
|
408
|
+
if (self.storyPhaseFilter === '(none)' ? !!s.phase : s.phase !== self.storyPhaseFilter) return false;
|
|
409
|
+
}
|
|
410
|
+
if (self.storySearch) {
|
|
411
|
+
var q = self.storySearch.toLowerCase();
|
|
412
|
+
return (
|
|
413
|
+
s.id.toLowerCase().indexOf(q) !== -1 ||
|
|
414
|
+
s.title.toLowerCase().indexOf(q) !== -1
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
filteredVcs() {
|
|
422
|
+
var self = this;
|
|
423
|
+
return this.vcsRefs.filter(function (v) {
|
|
424
|
+
if (self.vcsKindFilter !== 'all' && v.kind !== self.vcsKindFilter) return false;
|
|
425
|
+
if (self.vcsStateFilter !== 'all' && v.state !== self.vcsStateFilter) return false;
|
|
426
|
+
return true;
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
// =========================================================================
|
|
431
|
+
// Component filter options
|
|
432
|
+
// =========================================================================
|
|
433
|
+
|
|
434
|
+
reqComponentOptions() {
|
|
435
|
+
var all = [];
|
|
436
|
+
this.requirements.forEach(function (r) {
|
|
437
|
+
(r.components || []).forEach(function (c) {
|
|
438
|
+
if (all.indexOf(c) === -1) all.push(c);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
return all.sort();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
// =========================================================================
|
|
445
|
+
// Coverage lookups
|
|
446
|
+
// =========================================================================
|
|
447
|
+
|
|
448
|
+
reqCoverage(req) {
|
|
449
|
+
if (!this.coverage || !this.coverage.requirements) return null;
|
|
450
|
+
for (var i = 0; i < this.coverage.requirements.length; i++) {
|
|
451
|
+
if (this.coverage.requirements[i].id === req.id) return this.coverage.requirements[i];
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
storyCoverage(story) {
|
|
457
|
+
if (!this.coverage || !this.coverage.stories) return null;
|
|
458
|
+
for (var i = 0; i < this.coverage.stories.length; i++) {
|
|
459
|
+
if (this.coverage.stories[i].id === story.id) return this.coverage.stories[i];
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
_reqCoverageKey(req) {
|
|
465
|
+
var rc = this.reqCoverage(req);
|
|
466
|
+
if (!rc) return 'c_none';
|
|
467
|
+
if (rc.verified) return 'a_verified';
|
|
468
|
+
if (rc.hasStory) return 'b_hasStory';
|
|
469
|
+
return 'c_none';
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
// =========================================================================
|
|
473
|
+
// Badge helpers
|
|
474
|
+
// =========================================================================
|
|
475
|
+
|
|
476
|
+
reqStatusBadge(req) {
|
|
477
|
+
var rc = this.reqCoverage(req);
|
|
478
|
+
if (!rc) return { cls: 'badge-slate', icon: '–', label: '–' };
|
|
479
|
+
if (rc.verified) return { cls: 'badge-green', icon: '✓', label: 'verified' };
|
|
480
|
+
if (rc.hasStory) return { cls: 'badge-amber', icon: '~', label: 'has story' };
|
|
481
|
+
return { cls: 'badge-red', icon: '✗', label: 'no story' };
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
priorityBadge(p) {
|
|
485
|
+
var map = { critical: 'badge-red', high: 'badge-amber', medium: 'badge-blue', low: 'badge-slate' };
|
|
486
|
+
return map[p] || 'badge-slate';
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
storyStatusBadge(s) {
|
|
490
|
+
var map = { draft: 'badge-slate', ready: 'badge-blue', in_progress: 'badge-amber', done: 'badge-green' };
|
|
491
|
+
return map[s] || 'badge-slate';
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
vcsBadge(state) {
|
|
495
|
+
if (state === 'merged') return 'badge-green';
|
|
496
|
+
if (state === 'opened') return 'badge-blue';
|
|
497
|
+
return 'badge-slate';
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
phaseBadge(status) {
|
|
501
|
+
if (status === 'active' || status === 'completed') return 'badge-green';
|
|
502
|
+
return 'badge-slate';
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
coverageBadge(sc) {
|
|
506
|
+
if (!sc) return 'badge-slate';
|
|
507
|
+
if (sc.covered && sc.tested) return 'badge-green';
|
|
508
|
+
if (sc.tested) return 'badge-amber';
|
|
509
|
+
return 'badge-slate';
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
coverageLabel(sc) {
|
|
513
|
+
if (!sc) return 'not tracked';
|
|
514
|
+
if (sc.covered && sc.tested) return 'covered';
|
|
515
|
+
if (sc.tested) return 'tested';
|
|
516
|
+
return 'not tested';
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
// =========================================================================
|
|
520
|
+
// UI helpers
|
|
521
|
+
// =========================================================================
|
|
522
|
+
|
|
523
|
+
/** Return a number from the summary object, defaulting to 0. */
|
|
524
|
+
summaryVal(key) {
|
|
525
|
+
if (!this.summary) return 0;
|
|
526
|
+
var v = this.summary[key];
|
|
527
|
+
return (v !== undefined && v !== null) ? v : 0;
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
/** Format a percentage value (number) to one decimal place. */
|
|
531
|
+
pct(v) {
|
|
532
|
+
if (typeof v !== 'number') return '0.0';
|
|
533
|
+
return v.toFixed(1);
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
globalTotalReqs: function() {
|
|
537
|
+
return this.globalSummary.reduce(function(s, p) { return s + (p.requirements || 0); }, 0);
|
|
538
|
+
},
|
|
539
|
+
globalTotalStories: function() {
|
|
540
|
+
return this.globalSummary.reduce(function(s, p) { return s + (p.stories || 0); }, 0);
|
|
541
|
+
},
|
|
542
|
+
globalWeightedPct: function(field) {
|
|
543
|
+
var totalReqs = this.globalTotalReqs();
|
|
544
|
+
if (totalReqs === 0) return 0;
|
|
545
|
+
return this.globalSummary.reduce(function(s, p) {
|
|
546
|
+
return s + ((p[field] || 0) * (p.requirements || 0));
|
|
547
|
+
}, 0) / totalReqs;
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
activePhaseLabel() {
|
|
551
|
+
if (!this.summary || !this.summary.activePhase) return null;
|
|
552
|
+
var id = this.summary.activePhase;
|
|
553
|
+
for (var i = 0; i < this.phases.length; i++) {
|
|
554
|
+
if (this.phases[i].id === id) return this.phases[i].name || id;
|
|
555
|
+
}
|
|
556
|
+
return id;
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
componentName(id) {
|
|
560
|
+
for (var i = 0; i < this.components.length; i++) {
|
|
561
|
+
if (this.components[i].id === id) return this.components[i].name || id;
|
|
562
|
+
}
|
|
563
|
+
return id;
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/** Human label for a phase id (its name, falling back to the id). */
|
|
567
|
+
phaseName(id) {
|
|
568
|
+
if (!id) return '';
|
|
569
|
+
for (var i = 0; i < this.phases.length; i++) {
|
|
570
|
+
if (this.phases[i].id === id) return this.phases[i].name || id;
|
|
571
|
+
}
|
|
572
|
+
return id;
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
reqCountForComponent(componentId) {
|
|
576
|
+
var count = 0;
|
|
577
|
+
this.requirements.forEach(function (r) {
|
|
578
|
+
if ((r.components || []).indexOf(componentId) !== -1) count++;
|
|
579
|
+
});
|
|
580
|
+
return count;
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
toggleReq(id) {
|
|
584
|
+
this.reqExpanded = (this.reqExpanded === id) ? null : id;
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
toggleStory(id) {
|
|
588
|
+
this.storyExpanded = (this.storyExpanded === id) ? null : id;
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
sortReqBy(col) {
|
|
592
|
+
this.reqSortBy = col;
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
// =========================================================================
|
|
596
|
+
// Chart initialisation
|
|
597
|
+
// =========================================================================
|
|
598
|
+
|
|
599
|
+
initTrendChart(canvas) {
|
|
600
|
+
var self = this;
|
|
601
|
+
if (!canvas || typeof Chart === 'undefined') return;
|
|
602
|
+
// Destroy any stale instance so the new canvas gets a fresh Chart.js context
|
|
603
|
+
// (x-if can recycle the canvas reference while _trendChart still holds the old one).
|
|
604
|
+
if (self._trendChart) { self._trendChart.destroy(); self._trendChart = null; }
|
|
605
|
+
var ctx = canvas.getContext('2d');
|
|
606
|
+
|
|
607
|
+
self._trendChart = new Chart(ctx, {
|
|
608
|
+
type: 'line',
|
|
609
|
+
data: {
|
|
610
|
+
labels: [],
|
|
611
|
+
datasets: [
|
|
612
|
+
{
|
|
613
|
+
label: 'Verified %',
|
|
614
|
+
data: [],
|
|
615
|
+
borderColor: '#16a34a',
|
|
616
|
+
backgroundColor: 'rgba(22,163,74,0.08)',
|
|
617
|
+
tension: 0.4,
|
|
618
|
+
fill: true,
|
|
619
|
+
pointRadius: 4,
|
|
620
|
+
pointHoverRadius: 6,
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
label: 'Story Coverage %',
|
|
624
|
+
data: [],
|
|
625
|
+
borderColor: '#4f46e5',
|
|
626
|
+
backgroundColor: 'rgba(79,70,229,0.07)',
|
|
627
|
+
tension: 0.4,
|
|
628
|
+
fill: true,
|
|
629
|
+
pointRadius: 4,
|
|
630
|
+
pointHoverRadius: 6,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
options: {
|
|
635
|
+
responsive: true,
|
|
636
|
+
maintainAspectRatio: false,
|
|
637
|
+
interaction: { mode: 'index', intersect: false },
|
|
638
|
+
scales: {
|
|
639
|
+
y: {
|
|
640
|
+
min: 0, max: 100,
|
|
641
|
+
ticks: { callback: function (v) { return v + '%'; }, font: { size: 11 } },
|
|
642
|
+
grid: { color: 'rgba(0,0,0,0.04)' },
|
|
643
|
+
},
|
|
644
|
+
x: { ticks: { font: { size: 11 } }, grid: { display: false } },
|
|
645
|
+
},
|
|
646
|
+
plugins: {
|
|
647
|
+
legend: {
|
|
648
|
+
position: 'bottom',
|
|
649
|
+
labels: { font: { size: 12 }, usePointStyle: true },
|
|
650
|
+
},
|
|
651
|
+
tooltip: {
|
|
652
|
+
callbacks: {
|
|
653
|
+
label: function (ctx) {
|
|
654
|
+
return ' ' + ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
function applyTrend(data) {
|
|
663
|
+
if (!data || !self._trendChart) return;
|
|
664
|
+
self._trendChart.data.labels = data.map(function (p) { return p.phaseName || p.phase; });
|
|
665
|
+
self._trendChart.data.datasets[0].data = data.map(function (p) {
|
|
666
|
+
return p.summary ? Number((p.summary.verifiedPct || 0).toFixed(1)) : 0;
|
|
667
|
+
});
|
|
668
|
+
self._trendChart.data.datasets[1].data = data.map(function (p) {
|
|
669
|
+
return p.summary ? Number((p.summary.storyCoveragePct || 0).toFixed(1)) : 0;
|
|
670
|
+
});
|
|
671
|
+
self._trendChart.update('none');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
this.$watch('trend', applyTrend);
|
|
675
|
+
if (this.trend) applyTrend(this.trend);
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
initDonutChart(canvas) {
|
|
679
|
+
var self = this;
|
|
680
|
+
if (!canvas || typeof Chart === 'undefined') return;
|
|
681
|
+
// Same as trendChart: destroy any stale instance before re-init.
|
|
682
|
+
if (self._donutChart) { self._donutChart.destroy(); self._donutChart = null; }
|
|
683
|
+
var ctx = canvas.getContext('2d');
|
|
684
|
+
|
|
685
|
+
var COLORS = [
|
|
686
|
+
'#16a34a', '#4f46e5', '#f59e0b', '#ef4444',
|
|
687
|
+
'#06b6d4', '#8b5cf6', '#ec4899', '#14b8a6',
|
|
688
|
+
'#f97316', '#84cc16',
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
self._donutChart = new Chart(ctx, {
|
|
692
|
+
type: 'doughnut',
|
|
693
|
+
data: {
|
|
694
|
+
labels: [],
|
|
695
|
+
datasets: [{
|
|
696
|
+
data: [],
|
|
697
|
+
backgroundColor: [],
|
|
698
|
+
borderWidth: 2,
|
|
699
|
+
borderColor: '#fff',
|
|
700
|
+
hoverOffset: 6,
|
|
701
|
+
}],
|
|
702
|
+
},
|
|
703
|
+
options: {
|
|
704
|
+
responsive: true,
|
|
705
|
+
maintainAspectRatio: false,
|
|
706
|
+
cutout: '65%',
|
|
707
|
+
plugins: {
|
|
708
|
+
legend: {
|
|
709
|
+
position: 'right',
|
|
710
|
+
labels: { font: { size: 11 }, usePointStyle: true, padding: 10 },
|
|
711
|
+
},
|
|
712
|
+
tooltip: {
|
|
713
|
+
callbacks: {
|
|
714
|
+
label: function (ctx) {
|
|
715
|
+
return ' ' + ctx.label + ': ' + ctx.raw + '% verified';
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
function applyDonut(data) {
|
|
724
|
+
if (!data || !self._donutChart) return;
|
|
725
|
+
var by = data.byComponent || [];
|
|
726
|
+
self._donutChart.data.labels = by.map(function (c) { return c.component; });
|
|
727
|
+
self._donutChart.data.datasets[0].data = by.map(function (c) {
|
|
728
|
+
return Number((c.verifiedPct || 0).toFixed(1));
|
|
729
|
+
});
|
|
730
|
+
self._donutChart.data.datasets[0].backgroundColor = by.map(function (_, i) {
|
|
731
|
+
return COLORS[i % COLORS.length];
|
|
732
|
+
});
|
|
733
|
+
self._donutChart.update('none');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
this.$watch('coverage', applyDonut);
|
|
737
|
+
if (this.coverage) applyDonut(this.coverage);
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
// =========================================================================
|
|
741
|
+
// Export / Import
|
|
742
|
+
// =========================================================================
|
|
743
|
+
|
|
744
|
+
exportProject: function() {
|
|
745
|
+
var url = this.apiUrl('/api/export');
|
|
746
|
+
var slug = this.activeProject ? this.activeProject.slug : 'requ';
|
|
747
|
+
var a = document.createElement('a');
|
|
748
|
+
a.href = url;
|
|
749
|
+
a.download = slug + '-export.json';
|
|
750
|
+
document.body.appendChild(a);
|
|
751
|
+
a.click();
|
|
752
|
+
document.body.removeChild(a);
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
importFile: function(event) {
|
|
756
|
+
var self = this;
|
|
757
|
+
var file = event.target.files[0];
|
|
758
|
+
if (!file) return;
|
|
759
|
+
if (!file.name.endsWith('.json')) {
|
|
760
|
+
this.importResult = { errors: ['Please select a .json file exported from requ.'] };
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
self.importing = true;
|
|
764
|
+
self.importResult = null;
|
|
765
|
+
var inputEl = event.target;
|
|
766
|
+
var reader = new FileReader();
|
|
767
|
+
reader.onload = function(e) {
|
|
768
|
+
var text = e.target.result;
|
|
769
|
+
inputEl.value = ''; // reset so re-selecting same file fires @change again
|
|
770
|
+
fetch(self.apiUrl('/api/import'), {
|
|
771
|
+
method: 'POST',
|
|
772
|
+
headers: { 'Content-Type': 'application/json' },
|
|
773
|
+
body: text,
|
|
774
|
+
})
|
|
775
|
+
.then(function(res) {
|
|
776
|
+
return res.json().then(function(data) {
|
|
777
|
+
// jsonError shape is { error: "..." }; normalize to ImportReport shape
|
|
778
|
+
if (!res.ok) {
|
|
779
|
+
return { imported: {}, skipped: {}, errors: [data.error || 'Import failed (HTTP ' + res.status + ')'] };
|
|
780
|
+
}
|
|
781
|
+
return data;
|
|
782
|
+
});
|
|
783
|
+
})
|
|
784
|
+
.then(function(data) {
|
|
785
|
+
self.importing = false;
|
|
786
|
+
self.importResult = data;
|
|
787
|
+
// Reload all data to reflect imported content
|
|
788
|
+
self.loadSummary();
|
|
789
|
+
self.loadRequirements();
|
|
790
|
+
self.loadStories();
|
|
791
|
+
self.loadComponents();
|
|
792
|
+
self.loadPhases();
|
|
793
|
+
})
|
|
794
|
+
.catch(function(err) {
|
|
795
|
+
self.importing = false;
|
|
796
|
+
self.importResult = { errors: [String(err)] };
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
reader.readAsText(file);
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
importResultSummary: function() {
|
|
803
|
+
var r = this.importResult;
|
|
804
|
+
if (!r) return '';
|
|
805
|
+
var parts = [];
|
|
806
|
+
var imp = r.imported || {};
|
|
807
|
+
var skip = r.skipped || {};
|
|
808
|
+
var total = 0;
|
|
809
|
+
Object.keys(imp).forEach(function(k) { total += imp[k]; });
|
|
810
|
+
if (total > 0) parts.push('Imported ' + total + ' record' + (total !== 1 ? 's' : ''));
|
|
811
|
+
var skipTotal = 0;
|
|
812
|
+
Object.keys(skip).forEach(function(k) { skipTotal += skip[k].length; });
|
|
813
|
+
if (skipTotal > 0) parts.push(skipTotal + ' skipped (already exist)');
|
|
814
|
+
if (r.errors && r.errors.length > 0) parts.push(r.errors.length + ' error' + (r.errors.length !== 1 ? 's' : '') + ': ' + r.errors[0]);
|
|
815
|
+
return parts.length ? parts.join('. ') + '.' : 'Nothing to import.';
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
// =========================================================================
|
|
819
|
+
// Init from web UI
|
|
820
|
+
// =========================================================================
|
|
821
|
+
|
|
822
|
+
submitInit: function() {
|
|
823
|
+
var self = this;
|
|
824
|
+
if (self.setupSubmitting) return;
|
|
825
|
+
self.setupSubmitting = true;
|
|
826
|
+
self.setupError = null;
|
|
827
|
+
var body = {};
|
|
828
|
+
if (self.setupName) body.name = self.setupName;
|
|
829
|
+
if (self.setupKey) body.key = self.setupKey;
|
|
830
|
+
if (self.setupBrief) body.brief = self.setupBrief;
|
|
831
|
+
if (self.setupPhase) body.initialPhase = self.setupPhase;
|
|
832
|
+
fetch(self.apiUrl('/api/init'), {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers: { 'Content-Type': 'application/json' },
|
|
835
|
+
body: JSON.stringify(body),
|
|
836
|
+
})
|
|
837
|
+
.then(function(res) {
|
|
838
|
+
return res.json().then(function(data) {
|
|
839
|
+
if (!res.ok) {
|
|
840
|
+
return Promise.reject(data.error || ('Initialization failed (HTTP ' + res.status + ')'));
|
|
841
|
+
}
|
|
842
|
+
return data;
|
|
843
|
+
});
|
|
844
|
+
})
|
|
845
|
+
.then(function() {
|
|
846
|
+
self.setupSubmitting = false;
|
|
847
|
+
return Promise.all([
|
|
848
|
+
self.loadConfig(),
|
|
849
|
+
self.loadSummary(),
|
|
850
|
+
self.loadRequirements(),
|
|
851
|
+
self.loadStories(),
|
|
852
|
+
self.loadComponents(),
|
|
853
|
+
self.loadPhases(),
|
|
854
|
+
self.loadVcsRefs(),
|
|
855
|
+
self.loadCoverage(),
|
|
856
|
+
self.loadTrend(),
|
|
857
|
+
self.loadGaps(),
|
|
858
|
+
]);
|
|
859
|
+
})
|
|
860
|
+
.catch(function(err) {
|
|
861
|
+
self.setupSubmitting = false;
|
|
862
|
+
self.setupError = String(err);
|
|
863
|
+
});
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
// =========================================================================
|
|
867
|
+
// Brief inline edit
|
|
868
|
+
// =========================================================================
|
|
869
|
+
|
|
870
|
+
saveBrief: function() {
|
|
871
|
+
var self = this;
|
|
872
|
+
if (self.briefSaving) return;
|
|
873
|
+
self.briefSaving = true;
|
|
874
|
+
self.briefError = null;
|
|
875
|
+
fetch(self.apiUrl('/api/config'), {
|
|
876
|
+
method: 'PATCH',
|
|
877
|
+
headers: { 'Content-Type': 'application/json' },
|
|
878
|
+
body: JSON.stringify({ brief: self.briefDraft }),
|
|
879
|
+
})
|
|
880
|
+
.then(function(res) {
|
|
881
|
+
return res.json().then(function(data) {
|
|
882
|
+
if (!res.ok) return Promise.reject(data.error || ('Save failed (HTTP ' + res.status + ')'));
|
|
883
|
+
return data;
|
|
884
|
+
});
|
|
885
|
+
})
|
|
886
|
+
.then(function(data) {
|
|
887
|
+
self.briefSaving = false;
|
|
888
|
+
self.briefEditing = false;
|
|
889
|
+
self.briefExpanded = false;
|
|
890
|
+
self.briefOverflows = false;
|
|
891
|
+
self.config = data;
|
|
892
|
+
})
|
|
893
|
+
.catch(function(err) {
|
|
894
|
+
self.briefSaving = false;
|
|
895
|
+
self.briefError = String(err);
|
|
896
|
+
});
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
renderMarkdown: function(text) {
|
|
900
|
+
if (!text) return '';
|
|
901
|
+
if (window.marked) {
|
|
902
|
+
return window.marked.parse(text);
|
|
903
|
+
}
|
|
904
|
+
// safe plain-text fallback
|
|
905
|
+
return text
|
|
906
|
+
.replace(/&/g, '&')
|
|
907
|
+
.replace(/</g, '<')
|
|
908
|
+
.replace(/>/g, '>')
|
|
909
|
+
.replace(/\n/g, '<br>');
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
}; // end return
|
|
913
|
+
}); // end Alpine.data
|
|
914
|
+
}); // end addEventListener
|