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.
@@ -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, '&amp;')
907
+ .replace(/</g, '&lt;')
908
+ .replace(/>/g, '&gt;')
909
+ .replace(/\n/g, '<br>');
910
+ },
911
+
912
+ }; // end return
913
+ }); // end Alpine.data
914
+ }); // end addEventListener