react-doctor-cli-dev 1.0.7 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/backend/dist/db.js +33 -11
  2. package/backend/dist/index.js +29 -3
  3. package/backend/dist/routes/reports.js +106 -55
  4. package/backend/public/assets/index-BpODc0fS.css +1 -0
  5. package/backend/public/assets/index-zKyZPsv1.js +118 -0
  6. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  7. package/backend/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  8. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  9. package/backend/public/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  10. package/backend/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  11. package/backend/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  12. package/backend/public/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  13. package/backend/public/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  14. package/backend/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  15. package/backend/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  16. package/backend/public/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  17. package/backend/public/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  18. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  19. package/backend/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  20. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  21. package/backend/public/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  22. package/backend/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  23. package/backend/public/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  24. package/backend/public/assets/tajawal-arabic-300-normal-Bq0yWa0Z.woff +0 -0
  25. package/backend/public/assets/tajawal-arabic-300-normal-By07C9pa.woff2 +0 -0
  26. package/backend/public/assets/tajawal-arabic-400-normal-CyCXRvzh.woff2 +0 -0
  27. package/backend/public/assets/tajawal-arabic-400-normal-DCQxawbB.woff +0 -0
  28. package/backend/public/assets/tajawal-arabic-500-normal-BZ8ojJNu.woff2 +0 -0
  29. package/backend/public/assets/tajawal-arabic-500-normal-CbVEaYEW.woff +0 -0
  30. package/backend/public/assets/tajawal-arabic-700-normal-9L7Zusdl.woff +0 -0
  31. package/backend/public/assets/tajawal-arabic-700-normal-D2-eand5.woff2 +0 -0
  32. package/backend/public/assets/tajawal-latin-300-normal-C0-xR3ms.woff +0 -0
  33. package/backend/public/assets/tajawal-latin-300-normal-CeEKeOxZ.woff2 +0 -0
  34. package/backend/public/assets/tajawal-latin-400-normal-BVNSOH3d.woff2 +0 -0
  35. package/backend/public/assets/tajawal-latin-400-normal-BdYcZznU.woff +0 -0
  36. package/backend/public/assets/tajawal-latin-500-normal-CoYeBiSI.woff2 +0 -0
  37. package/backend/public/assets/tajawal-latin-500-normal-DU9v6xgj.woff +0 -0
  38. package/backend/public/assets/tajawal-latin-700-normal-BypgxfGb.woff2 +0 -0
  39. package/backend/public/assets/tajawal-latin-700-normal-CV3bxpHe.woff +0 -0
  40. package/backend/public/favicon.svg +1 -0
  41. package/backend/public/icons.svg +24 -0
  42. package/backend/public/index.html +254 -0
  43. package/backend/src/db.ts +42 -18
  44. package/backend/src/index.ts +31 -3
  45. package/backend/src/routes/reports.ts +141 -52
  46. package/cli/dist/commands/full.js +82 -48
  47. package/cli/src/commands/full.ts +161 -115
  48. package/dashboard/index.html +253 -0
  49. package/dashboard/package-lock.json +913 -0
  50. package/dashboard/package.json +19 -0
  51. package/dashboard/public/favicon.svg +1 -0
  52. package/dashboard/public/icons.svg +24 -0
  53. package/dashboard/src/api.js +107 -0
  54. package/dashboard/src/assets/hero.png +0 -0
  55. package/dashboard/src/assets/javascript.svg +1 -0
  56. package/dashboard/src/assets/vite.svg +1 -0
  57. package/dashboard/src/css/main.css +441 -0
  58. package/dashboard/src/data.js +202 -0
  59. package/dashboard/src/main.js +53 -0
  60. package/dashboard/src/pages.js +797 -0
  61. package/dashboard/src/router.js +77 -0
  62. package/dashboard/src/utils.js +163 -0
  63. package/dashboard/vite.config.js +19 -0
  64. package/data/screenshots/5--fcp.png +0 -0
  65. package/data/screenshots/5--fullLoad.png +0 -0
  66. package/data/screenshots/5-docs-fullLoad.png +0 -0
  67. package/data/screenshots/5-white-fullLoad.png +0 -0
  68. package/data/screenshots/6--fcp.png +0 -0
  69. package/data/screenshots/6--fullLoad.png +0 -0
  70. package/data/screenshots/6-docs-fullLoad.png +0 -0
  71. package/data/screenshots/6-white-fullLoad.png +0 -0
  72. package/package.json +4 -1
@@ -0,0 +1,797 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // js/pages.js — Complete Fixed Version
3
+ // All pages fetch live data from the backend API
4
+ // ─────────────────────────────────────────────────────────────
5
+
6
+ import { Chart } from 'chart.js';
7
+ import api from './api.js';
8
+ import { REPORT_DATA, HISTORY_DATA } from './data.js';
9
+ import {
10
+ scoreColor, gradeBadgeClass, severityIcon, severityLabel,
11
+ vitalClass, vitalColor, vitalBarPct,
12
+ formatMs, formatCls, formatDate, relativeTime,
13
+ buildScoreRing, buildVitalRow,
14
+ openLightbox, closeLightbox,
15
+ } from './utils.js';
16
+
17
+ // ── Normalize API data to match expected structure ──────────
18
+ function normalizeReport(apiData) {
19
+ if (apiData.static && apiData.runtime && apiData.suggestions) {
20
+ return apiData;
21
+ }
22
+
23
+ const score = apiData.performanceScore || apiData.score || 0;
24
+
25
+ return {
26
+ projectName: apiData.project || apiData.projectName || 'Unknown',
27
+ analyzedAt: apiData.analyzedAt || apiData.analyzed_at || new Date().toISOString(),
28
+ performanceScore: score,
29
+ score: score,
30
+ overallScore: score,
31
+ static: apiData.static || {
32
+ issues: apiData.issues || [],
33
+ grade: apiData.grade || 'N/A',
34
+ componentCount: apiData.componentCount || 0,
35
+ filesAnalyzed: apiData.filesAnalyzed || 0,
36
+ },
37
+ runtime: apiData.runtime || {},
38
+ suggestions: apiData.suggestions || [],
39
+ };
40
+ }
41
+
42
+ // ══════════════════════════════════════════════════════════════
43
+ // OVERVIEW PAGE
44
+ // ══════════════════════════════════════════════════════════════
45
+ let overviewDone = false;
46
+ let cachedReport = null;
47
+
48
+ export async function initOverview() {
49
+ if (overviewDone) return;
50
+ overviewDone = true;
51
+
52
+ let R;
53
+ try {
54
+ const rawData = await api.getLatestReport();
55
+ R = normalizeReport(rawData);
56
+ cachedReport = R;
57
+ console.log('📊 Overview loaded:', R);
58
+ } catch (err) {
59
+ console.warn('⚠️ Using static fallback:', err.message);
60
+ R = REPORT_DATA;
61
+ }
62
+
63
+ const st = R.static || { issues: [], grade: 'N/A' };
64
+ const el = id => document.getElementById(id);
65
+
66
+ const score = R.performanceScore || R.score || R.overallScore || 0;
67
+
68
+ const scoreRingEl = el("ov-score-ring");
69
+ if (scoreRingEl) {
70
+ scoreRingEl.innerHTML = buildScoreRing(score, 148, 11);
71
+ }
72
+
73
+ const grade = st.grade || 'N/A';
74
+ const gc = gradeBadgeClass(grade);
75
+ const gradeEl = el("ov-grade");
76
+ if (gradeEl) {
77
+ gradeEl.innerHTML = `<span class="badge ${gc}">${grade}</span>`;
78
+ }
79
+
80
+ const issues = st.issues || [];
81
+ const suggestions = R.suggestions || [];
82
+ const critical = suggestions.filter(s => s.severity === "critical").length;
83
+ const warning = suggestions.filter(s => s.severity === "warning").length;
84
+ const summaryEl = el("ov-summary");
85
+ if (summaryEl) {
86
+ summaryEl.textContent =
87
+ `Score: ${score}/100 · ` +
88
+ `Analyzed ${st.filesAnalyzed || 0} files · ${st.componentCount || 0} components · ` +
89
+ `${critical} critical issue${critical !== 1 ? "s" : ""} · ${warning} warnings`;
90
+ }
91
+
92
+ const projectEl = el("ov-project");
93
+ if (projectEl) {
94
+ projectEl.textContent = R.projectName || R.project || 'Unknown';
95
+ }
96
+
97
+ const analyzedEl = el("ov-analyzed");
98
+ if (analyzedEl) {
99
+ analyzedEl.textContent = formatDate(R.analyzedAt || R.analyzed_at);
100
+ }
101
+
102
+ const runtime = R.runtime || {};
103
+ const routes = Object.values(runtime);
104
+ const avgLcp = routes.length ? routes.reduce((s, r) => s + (r.metrics?.lcp || 0), 0) / routes.length : 0;
105
+ const avgFcp = routes.length ? routes.reduce((s, r) => s + (r.metrics?.fcp || 0), 0) / routes.length : 0;
106
+ const avgCls = routes.length ? routes.reduce((s, r) => s + (r.metrics?.cls || 0), 0) / routes.length : 0;
107
+ const totalIssues = issues.length;
108
+
109
+ function tile(elId, metricKey, value, display) {
110
+ const cls = metricKey === "none" ? "blue" : vitalClass(metricKey, value);
111
+ const el2 = document.getElementById(elId);
112
+ if (!el2) return;
113
+ el2.className = `stat-tile ${cls}`;
114
+ const valEl = el2.querySelector(".stat-value");
115
+ if (valEl) {
116
+ valEl.className = `stat-value ${cls === "good" ? "good" : cls === "warn" ? "warn" : cls === "bad" ? "bad" : "blue"}`;
117
+ valEl.textContent = display;
118
+ }
119
+ }
120
+
121
+ tile("tile-lcp", "lcp", avgLcp, formatMs(avgLcp));
122
+ tile("tile-fcp", "fcp", avgFcp, formatMs(avgFcp));
123
+ tile("tile-cls", "cls", avgCls, formatCls(avgCls));
124
+ tile("tile-issues", "none", 0, totalIssues);
125
+
126
+ const issuesTile = document.getElementById("tile-issues");
127
+ if (issuesTile) {
128
+ const issCls = critical > 0 ? "bad" : warning > 0 ? "warn" : "good";
129
+ issuesTile.className = `stat-tile ${issCls}`;
130
+ const valEl = issuesTile.querySelector(".stat-value");
131
+ if (valEl) valEl.className = `stat-value ${issCls}`;
132
+ }
133
+
134
+ const sugWrap = el("ov-suggestions");
135
+ if (sugWrap) {
136
+ const topSuggestions = suggestions.slice(0, 3);
137
+ sugWrap.innerHTML = topSuggestions.length ?
138
+ topSuggestions.map(buildSuggestionCard).join("") :
139
+ '<div style="color:var(--text3);padding:12px;text-align:center;">No suggestions available</div>';
140
+ }
141
+
142
+ const routeWrap = el("ov-routes");
143
+ if (routeWrap) {
144
+ const entries = Object.entries(runtime);
145
+ if (entries.length === 0) {
146
+ routeWrap.innerHTML = `<div style="color:var(--text3);padding:12px;text-align:center;">No runtime data available</div>`;
147
+ } else {
148
+ const rows = entries.map(([key, r]) => {
149
+ const [path, dev] = key.split("::");
150
+ const sc = r.performanceScore || 0;
151
+ const clr = scoreColor(sc);
152
+ const metrics = r.metrics || {};
153
+ const errors = r.errors || [];
154
+ return `<tr>
155
+ <td class="mono">${path || '/'}</td>
156
+ <td><span class="badge info">${dev || 'desktop'}</span></td>
157
+ <td class="score-cell" style="color:${clr}">${sc}</td>
158
+ <td>${formatMs(metrics.lcp || 0)}</td>
159
+ <td>${formatMs(metrics.fcp || 0)}</td>
160
+ <td><span class="badge ${vitalClass("cls", metrics.cls || 0)}">${formatCls(metrics.cls || 0)}</span></td>
161
+ <td>${errors.length > 0 ? `<span class="badge critical">${errors.length}</span>` : '<span class="badge good">0</span>'}</td>
162
+ </tr>`;
163
+ }).join("");
164
+ routeWrap.innerHTML = `
165
+ <table class="rd-table">
166
+ <thead><tr>
167
+ <th>Route</th><th>Device</th><th>Score</th>
168
+ <th>LCP</th><th>FCP</th><th>CLS</th><th>Errors</th>
169
+ </tr></thead>
170
+ <tbody>${rows}</tbody>
171
+ </table>`;
172
+ }
173
+ }
174
+ }
175
+
176
+ // ══════════════════════════════════════════════════════════════
177
+ // VITALS PAGE
178
+ // ══════════════════════════════════════════════════════════════
179
+ let vitalsDone = false;
180
+ let vitalsReport = null;
181
+
182
+ export async function initVitals() {
183
+ if (vitalsDone) return;
184
+ vitalsDone = true;
185
+
186
+ let R;
187
+ try {
188
+ const rawData = await api.getLatestReport();
189
+ R = normalizeReport(rawData);
190
+ vitalsReport = R;
191
+ console.log('📊 Vitals loaded:', R);
192
+ } catch (err) {
193
+ console.warn('⚠️ Using static fallback for vitals:', err.message);
194
+ R = REPORT_DATA;
195
+ vitalsReport = R;
196
+ }
197
+
198
+ const runtime = R.runtime || {};
199
+ const routes = Object.entries(runtime);
200
+ if (routes.length === 0) {
201
+ const tabsEl = document.getElementById("vitals-tabs");
202
+ if (tabsEl) {
203
+ tabsEl.innerHTML = `<div style="color:var(--text3);padding:12px;">No runtime data available</div>`;
204
+ }
205
+ return;
206
+ }
207
+
208
+ const routeKeys = routes.map(([k]) => k);
209
+ let activeRoute = routeKeys[0];
210
+
211
+ const tabsEl = document.getElementById("vitals-tabs");
212
+ if (tabsEl) {
213
+ tabsEl.innerHTML = routeKeys.map((k, i) => {
214
+ const [path, dev] = k.split("::");
215
+ return `<button class="route-tab${i === 0 ? " active" : ""}" data-key="${k}">${path || '/'} <span style="opacity:.6">[${dev || 'desktop'}]</span></button>`;
216
+ }).join("");
217
+
218
+ tabsEl.querySelectorAll(".route-tab").forEach(btn => {
219
+ btn.addEventListener("click", () => {
220
+ tabsEl.querySelectorAll(".route-tab").forEach(b => b.classList.remove("active"));
221
+ btn.classList.add("active");
222
+ activeRoute = btn.dataset.key;
223
+ renderVitalsRoute(activeRoute);
224
+ });
225
+ });
226
+ }
227
+
228
+ renderVitalsChart(routes);
229
+ renderVitalsRoute(activeRoute);
230
+ }
231
+
232
+ function renderVitalsChart(routes) {
233
+ const ctx = document.getElementById("vitals-chart");
234
+ if (!ctx) return;
235
+
236
+ const labels = routes.map(([k]) => {
237
+ const [path, dev] = k.split("::");
238
+ return path || '/';
239
+ });
240
+ const metrics = ["lcp", "fcp", "ttfb"];
241
+ const colors = ["#C778DD", "#00FFC2", "#58A6FF"];
242
+ const names = ["LCP (ms)", "FCP (ms)", "TTFB (ms)"];
243
+
244
+ if (ctx._chart) ctx._chart.destroy();
245
+ ctx._chart = new Chart(ctx, {
246
+ type: "bar",
247
+ data: {
248
+ labels,
249
+ datasets: metrics.map((m, i) => ({
250
+ label: names[i],
251
+ data: routes.map(([, r]) => r.metrics?.[m] || 0),
252
+ backgroundColor: colors[i] + "33",
253
+ borderColor: colors[i],
254
+ borderWidth: 1.5,
255
+ borderRadius: 4,
256
+ }))
257
+ },
258
+ options: {
259
+ responsive: true,
260
+ maintainAspectRatio: false,
261
+ plugins: {
262
+ legend: { labels: { color: "#8B949E", font: { family: "Inter", size: 11 } } }
263
+ },
264
+ scales: {
265
+ x: { ticks: { color: "#6E7681", font: { size: 10 } }, grid: { color: "#21262D" } },
266
+ y: { ticks: { color: "#6E7681" }, grid: { color: "#21262D" } }
267
+ }
268
+ }
269
+ });
270
+ }
271
+
272
+ function renderVitalsRoute(key) {
273
+ const R = vitalsReport || REPORT_DATA;
274
+ const runtime = R.runtime || {};
275
+ const r = runtime[key];
276
+ if (!r) {
277
+ const barsEl = document.getElementById("vitals-bars");
278
+ if (barsEl) {
279
+ barsEl.innerHTML = `<div style="color:var(--text3);padding:12px;">No data for this route</div>`;
280
+ }
281
+ return;
282
+ }
283
+
284
+ const m = r.metrics || {};
285
+
286
+ // ── Bars with Render Time added ──────────────────────────
287
+ const barsEl = document.getElementById("vitals-bars");
288
+ if (barsEl) {
289
+ barsEl.innerHTML = [
290
+ buildVitalRow("Largest Contentful Paint (LCP)", "lcp", m.lcp || 0),
291
+ buildVitalRow("First Contentful Paint (FCP)", "fcp", m.fcp || 0),
292
+ buildVitalRow("Time to First Byte (TTFB)", "ttfb", m.ttfb || 0),
293
+ buildVitalRow("Interaction to Next Paint (INP)", "inp", m.inp || 0),
294
+ buildVitalRow("Cumulative Layout Shift (CLS)", "cls", m.cls || 0),
295
+ buildVitalRow("Render Time (Total)", "render", r.renderTime || 0),
296
+ ].join("");
297
+ }
298
+
299
+ // ── Meta section with Render Time highlighted ────────────
300
+ const stats = r.stats || {};
301
+ const metaEl = document.getElementById("vitals-meta");
302
+ if (metaEl) {
303
+ metaEl.innerHTML = `
304
+ <span class="badge info">${r.deviceType || 'desktop'}</span>
305
+ <span style="color:var(--text3);font-size:.75rem">CPU ×${r.cpuThrottling || 1}</span>
306
+ <span style="color:var(--text3);font-size:.75rem">${r.networkThrottle || 'No throttle'}</span>
307
+ <span style="color:var(--teal);font-size:.75rem;font-weight:bold">⏱ Render ${formatMs(r.renderTime || 0)}</span>
308
+ <span style="color:var(--text3);font-size:.75rem">${stats.domNodes || 0} DOM nodes</span>
309
+ <span style="color:var(--text3);font-size:.75rem">${stats.jsHeapMB || '—'} MB heap</span>`;
310
+ }
311
+
312
+ const sc = r.performanceScore || 0;
313
+ const clr = scoreColor(sc);
314
+ const scoreEl = document.getElementById("vitals-score");
315
+ if (scoreEl) {
316
+ scoreEl.innerHTML =
317
+ `<span style="font-family:'JetBrains Mono',monospace;font-size:1.6rem;font-weight:700;color:${clr}">${sc}</span>
318
+ <span style="color:var(--text3);font-size:.78rem">/ 100</span>`;
319
+ }
320
+
321
+ const rerenders = Object.entries(r.rerenders || {}).sort((a, b) => b[1] - a[1]);
322
+ const rerendersEl = document.getElementById("vitals-rerenders");
323
+ if (rerendersEl) {
324
+ rerendersEl.innerHTML = rerenders.length === 0
325
+ ? `<div style="color:var(--text3);padding:8px 0">No re-render data available</div>`
326
+ : `
327
+ <table class="rd-table">
328
+ <thead><tr><th>Component</th><th>Re-renders</th></tr></thead>
329
+ <tbody>${rerenders.map(([name, count]) => `
330
+ <tr>
331
+ <td class="mono">${name}</td>
332
+ <td><span style="color:${count > 10 ? "var(--red)" : count > 5 ? "var(--orange)" : "var(--green)"};font-family:'JetBrains Mono',monospace;font-weight:600">${count}</span></td>
333
+ </tr>`).join("")}
334
+ </tbody>
335
+ </table>`;
336
+ }
337
+
338
+ const errors = r.errors || [];
339
+ const errorsEl = document.getElementById("vitals-errors");
340
+ if (errorsEl) {
341
+ errorsEl.innerHTML = errors.length === 0
342
+ ? `<div style="color:var(--text3);font-size:.82rem;padding:8px 0">✅ No JS errors or React warnings captured</div>`
343
+ : errors.map(e => `
344
+ <div style="display:flex;gap:10px;align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--border)">
345
+ <span class="badge ${e.type === "error" ? "critical" : "warning"}">${e.type || 'warning'}</span>
346
+ <span style="font-size:.78rem;font-family:'JetBrains Mono',monospace;color:var(--text2);word-break:break-word">${e.message}</span>
347
+ </div>`).join("");
348
+ }
349
+
350
+ // ── Screenshots ──────────────────────────────────────────
351
+ renderFilmstrip("vitals-filmstrip", r.screenshots || [], key);
352
+ }
353
+
354
+ // ══════════════════════════════════════════════════════════════
355
+ // ISSUES PAGE
356
+ // ══════════════════════════════════════════════════════════════
357
+ const ISSUES_PER_PAGE = 8;
358
+ let issuesFilter = "all";
359
+ let issuesPage = 1;
360
+ let issuesDone = false;
361
+ let issuesData = [];
362
+
363
+ export async function initIssues() {
364
+ if (issuesDone) return;
365
+ issuesDone = true;
366
+
367
+ try {
368
+ const rawData = await api.getLatestReport();
369
+ const R = normalizeReport(rawData);
370
+ issuesData = R.static?.issues || [];
371
+ console.log('📊 Issues loaded:', issuesData.length);
372
+ } catch (err) {
373
+ console.warn('⚠️ Using static fallback for issues:', err.message);
374
+ issuesData = REPORT_DATA.static?.issues || [];
375
+ }
376
+
377
+ const btns = document.querySelectorAll("#issues-filters .filter-btn");
378
+ btns.forEach(btn => {
379
+ btn.addEventListener("click", () => {
380
+ btns.forEach(b => b.classList.remove("active"));
381
+ btn.classList.add("active");
382
+ issuesFilter = btn.dataset.filter;
383
+ issuesPage = 1;
384
+ renderIssues();
385
+ });
386
+ });
387
+
388
+ renderIssues();
389
+ }
390
+
391
+ function renderIssues() {
392
+ const all = issuesData;
393
+ const filtered = issuesFilter === "all"
394
+ ? all
395
+ : all.filter(i => i.severity === issuesFilter);
396
+
397
+ const totalPages = Math.ceil(filtered.length / ISSUES_PER_PAGE) || 1;
398
+ const page = filtered.slice((issuesPage - 1) * ISSUES_PER_PAGE, issuesPage * ISSUES_PER_PAGE);
399
+
400
+ const wrap = document.getElementById("issues-table");
401
+ if (!wrap) return;
402
+
403
+ if (all.length === 0) {
404
+ wrap.innerHTML = `<div style="color:var(--text3);padding:12px;text-align:center;">No issues found</div>`;
405
+ } else {
406
+ wrap.innerHTML = `
407
+ <table class="rd-table">
408
+ <thead><tr>
409
+ <th>Severity</th>
410
+ <th>Component</th>
411
+ <th>File</th>
412
+ <th>Line</th>
413
+ <th>Issue</th>
414
+ <th>Fix</th>
415
+ </tr></thead>
416
+ <tbody>${page.map(issue => `
417
+ <tr>
418
+ <td>${severityLabel(issue.severity)}</td>
419
+ <td class="mono">${issue.component || '—'}</td>
420
+ <td class="file">${issue.file || '—'}</td>
421
+ <td class="mono" style="color:var(--text3)">${issue.line || '—'}</td>
422
+ <td style="color:var(--text);font-size:.8rem">${issue.message}</td>
423
+ <td style="font-size:.76rem;color:var(--text2)">${issue.suggestion || '—'}</td>
424
+ </tr>`).join("")}
425
+ </tbody>
426
+ </table>`;
427
+ }
428
+
429
+ const counts = { all: all.length };
430
+ ["critical", "warning", "info"].forEach(s => counts[s] = all.filter(i => i.severity === s).length);
431
+ document.querySelectorAll("#issues-filters .filter-btn").forEach(btn => {
432
+ const f = btn.dataset.filter;
433
+ let badge = btn.querySelector(".nav-badge");
434
+ if (!badge) {
435
+ badge = document.createElement("span");
436
+ badge.className = "nav-badge";
437
+ btn.appendChild(badge);
438
+ }
439
+ badge.textContent = counts[f] || 0;
440
+ });
441
+
442
+ renderPagination("issues-pagination", issuesPage, totalPages, p => {
443
+ issuesPage = p;
444
+ renderIssues();
445
+ });
446
+ }
447
+
448
+ // ══════════════════════════════════════════════════════════════
449
+ // SUGGESTIONS PAGE
450
+ // ══════════════════════════════════════════════════════════════
451
+ let sugFilter = "all";
452
+ let sugPage = 1;
453
+ let sugDone = false;
454
+ const SUGS_PER_PAGE = 5;
455
+ let suggestionsData = [];
456
+
457
+ export async function initSuggestions() {
458
+ if (sugDone) return;
459
+ sugDone = true;
460
+
461
+ try {
462
+ const rawData = await api.getLatestReport();
463
+ const R = normalizeReport(rawData);
464
+ suggestionsData = R.suggestions || [];
465
+ console.log('📊 Suggestions loaded:', suggestionsData.length);
466
+ } catch (err) {
467
+ console.warn('⚠️ Using static fallback for suggestions:', err.message);
468
+ suggestionsData = REPORT_DATA.suggestions || [];
469
+ }
470
+
471
+ document.querySelectorAll("#sug-filters .filter-btn").forEach(btn => {
472
+ btn.addEventListener("click", () => {
473
+ document.querySelectorAll("#sug-filters .filter-btn").forEach(b => b.classList.remove("active"));
474
+ btn.classList.add("active");
475
+ sugFilter = btn.dataset.filter;
476
+ sugPage = 1;
477
+ renderSuggestions();
478
+ });
479
+ });
480
+
481
+ renderSuggestions();
482
+ }
483
+
484
+ function renderSuggestions() {
485
+ const all = suggestionsData;
486
+ const filtered = sugFilter === "all" ? all : all.filter(s => s.severity === sugFilter);
487
+ const totalPages = Math.ceil(filtered.length / SUGS_PER_PAGE) || 1;
488
+ const page = filtered.slice((sugPage - 1) * SUGS_PER_PAGE, sugPage * SUGS_PER_PAGE);
489
+
490
+ const listEl = document.getElementById("sug-list");
491
+ if (!listEl) return;
492
+
493
+ if (all.length === 0) {
494
+ listEl.innerHTML = `<div style="color:var(--text3);padding:12px;text-align:center;">No suggestions available</div>`;
495
+ } else {
496
+ listEl.innerHTML = page.map(buildSuggestionCard).join("");
497
+ }
498
+
499
+ renderPagination("sug-pagination", sugPage, totalPages, p => {
500
+ sugPage = p;
501
+ renderSuggestions();
502
+ });
503
+
504
+ const counts = {};
505
+ ["critical", "warning", "info"].forEach(s => counts[s] = all.filter(x => x.severity === s).length);
506
+ const countsEl = document.getElementById("sug-counts");
507
+ if (countsEl) {
508
+ countsEl.innerHTML =
509
+ `<span class="badge critical">🔴 ${counts.critical || 0} critical</span>
510
+ <span class="badge warning">🟠 ${counts.warning || 0} warnings</span>
511
+ <span class="badge info">🔵 ${counts.info || 0} info</span>`;
512
+ }
513
+ }
514
+
515
+ // ══════════════════════════════════════════════════════════════
516
+ // HISTORY PAGE
517
+ // ══════════════════════════════════════════════════════════════
518
+ const HIST_PER_PAGE = 6;
519
+ let histPage = 1;
520
+ let histDone = false;
521
+ let historyData = [];
522
+
523
+ export async function initHistory() {
524
+ if (histDone) return;
525
+ histDone = true;
526
+
527
+ try {
528
+ const result = await api.listReports();
529
+ historyData = result.reports || [];
530
+ console.log('📊 History loaded:', historyData.length);
531
+ } catch (err) {
532
+ console.warn('⚠️ Using static fallback for history:', err.message);
533
+ historyData = HISTORY_DATA;
534
+ }
535
+
536
+ renderHistory();
537
+ renderHistoryChart();
538
+ }
539
+
540
+ function renderHistory() {
541
+ const total = Math.ceil(historyData.length / HIST_PER_PAGE) || 1;
542
+ const page = historyData.slice((histPage - 1) * HIST_PER_PAGE, histPage * HIST_PER_PAGE);
543
+
544
+ const tableEl = document.getElementById("hist-table");
545
+ if (!tableEl) return;
546
+
547
+ if (historyData.length === 0) {
548
+ tableEl.innerHTML = `<div style="color:var(--text3);padding:12px;text-align:center;">No history available</div>`;
549
+ } else {
550
+ tableEl.innerHTML = `
551
+ <table class="rd-table">
552
+ <thead><tr>
553
+ <th>#</th><th>Project</th><th>Score</th><th>Grade</th>
554
+ <th>Analyzed</th><th>Saved</th>
555
+ </tr></thead>
556
+ <tbody>${page.map(r => {
557
+ const clr = scoreColor(r.score);
558
+ const gc = gradeBadgeClass(r.grade);
559
+ return `<tr style="cursor:pointer" onclick="alert('Open report #${r.id}')">
560
+ <td class="mono" style="color:var(--text3)">${r.id}</td>
561
+ <td><span class="mono">${r.project}</span></td>
562
+ <td class="score-cell" style="color:${clr}">${r.score}</td>
563
+ <td><span class="badge ${gc}">${r.grade}</span></td>
564
+ <td style="font-size:.78rem">${formatDate(r.analyzed_at)}</td>
565
+ <td style="font-size:.75rem;color:var(--text3)">${relativeTime(r.created_at)}</td>
566
+ </tr>`;
567
+ }).join("")}</tbody>
568
+ </table>`;
569
+ }
570
+
571
+ renderPagination("hist-pagination", histPage, total, p => {
572
+ histPage = p;
573
+ renderHistory();
574
+ });
575
+ }
576
+
577
+ function renderHistoryChart() {
578
+ const ctx = document.getElementById("hist-chart");
579
+ if (!ctx) return;
580
+
581
+ if (historyData.length === 0) {
582
+ return;
583
+ }
584
+
585
+ const projectName = historyData[0]?.project || "Unknown Project";
586
+ const titleEl = document.querySelector('#page-history .card:first-child .card-title');
587
+ if (titleEl) {
588
+ titleEl.textContent = `Score Trend — ${projectName}`;
589
+ }
590
+
591
+ const trend = historyData
592
+ .filter(r => r.project === projectName)
593
+ .reverse();
594
+
595
+ if (trend.length === 0) return;
596
+
597
+ if (ctx._chart) ctx._chart.destroy();
598
+ ctx._chart = new Chart(ctx, {
599
+ type: "line",
600
+ data: {
601
+ labels: trend.map(r => formatDate(r.analyzed_at).split(" ")[0]),
602
+ datasets: [{
603
+ label: "Performance Score",
604
+ data: trend.map(r => r.score),
605
+ borderColor: "#C778DD",
606
+ backgroundColor: "rgba(199,120,221,0.08)",
607
+ borderWidth: 2,
608
+ pointBackgroundColor: "#C778DD",
609
+ pointRadius: 4,
610
+ fill: true,
611
+ tension: 0.4,
612
+ }]
613
+ },
614
+ options: {
615
+ responsive: true,
616
+ maintainAspectRatio: false,
617
+ plugins: {
618
+ legend: {
619
+ labels: { color: "#8B949E", font: { size: 11 } }
620
+ }
621
+ },
622
+ scales: {
623
+ x: {
624
+ ticks: { color: "#6E7681", font: { size: 10 } },
625
+ grid: { color: "#21262D" }
626
+ },
627
+ y: {
628
+ min: 0,
629
+ max: 100,
630
+ ticks: { color: "#6E7681" },
631
+ grid: { color: "#21262D" }
632
+ }
633
+ }
634
+ }
635
+ });
636
+ }
637
+
638
+ // ══════════════════════════════════════════════════════════════
639
+ // SHARED HELPERS
640
+ // ══════════════════════════════════════════════════════════════
641
+
642
+ function buildSuggestionCard(s) {
643
+ const icon = { critical: "🔴", warning: "🟠", info: "🔵" }[s.severity] || "⚪";
644
+ return `
645
+ <div class="suggestion-card">
646
+ <div class="sug-icon ${s.severity}">${icon}</div>
647
+ <div class="sug-body">
648
+ <div class="sug-title">
649
+ ${s.title || 'Untitled suggestion'}
650
+ <span class="badge ${s.severity}">${s.severity}</span>
651
+ </div>
652
+ <div class="sug-desc">${s.description || 'No description'}</div>
653
+ <div class="sug-fix">${s.fix || 'No fix provided'}</div>
654
+ ${s.affectedComponent ? `<div class="sug-component">${s.affectedComponent}</div>` : ""}
655
+ </div>
656
+ </div>`;
657
+ }
658
+
659
+ // ── FIXED: Screenshots rendering with deduplication ──────────
660
+ function renderFilmstrip(elId, screenshots, routeKey) {
661
+ const el = document.getElementById(elId);
662
+ if (!el) return;
663
+
664
+ // Clear the element
665
+ el.innerHTML = "";
666
+
667
+ // Check if we have screenshots
668
+ if (!screenshots || screenshots.length === 0) {
669
+ el.innerHTML = `<div class="film-placeholder"><span>📸 No screenshots<br>captured</span></div>`;
670
+ return;
671
+ }
672
+
673
+ // ── Filter out invalid screenshots ──────────────────────
674
+ const validScreenshots = screenshots.filter(s => {
675
+ if (!s.dataUrl) return false;
676
+ // Skip placeholder/pending screenshots
677
+ if (s.dataUrl.startsWith('__PENDING__')) return false;
678
+ if (s.dataUrl === 'null' || s.dataUrl === 'undefined') return false;
679
+ return true;
680
+ });
681
+
682
+ // ── Deduplicate by label (keep only one of each label) ──
683
+ const seenLabels = new Set();
684
+ const uniqueScreenshots = validScreenshots.filter(s => {
685
+ const label = s.label || 'screenshot';
686
+ if (seenLabels.has(label)) return false;
687
+ seenLabels.add(label);
688
+ return true;
689
+ });
690
+
691
+ if (uniqueScreenshots.length === 0) {
692
+ el.innerHTML = `<div class="film-placeholder"><span>📸 No valid screenshots<br>available</span></div>`;
693
+ return;
694
+ }
695
+
696
+ // ── Render each unique screenshot ──────────────────────
697
+ uniqueScreenshots.forEach((s, index) => {
698
+ const frame = document.createElement("div");
699
+ frame.className = "film-frame";
700
+
701
+ const img = document.createElement("img");
702
+ img.className = "film-img";
703
+
704
+ const label = s.label || `screenshot-${index}`;
705
+ const time = s.takenAt || 0;
706
+
707
+ // ── Handle different dataUrl formats ──────────────────
708
+ if (s.dataUrl && s.dataUrl.startsWith('data:image')) {
709
+ // It's a base64 image - use it directly
710
+ img.src = s.dataUrl;
711
+ img.alt = `${label} screenshot`;
712
+ } else if (s.dataUrl && s.dataUrl.startsWith('http')) {
713
+ // It's a URL - use it
714
+ img.src = s.dataUrl;
715
+ img.alt = `${label} screenshot`;
716
+ } else if (s.dataUrl && s.dataUrl.startsWith('/screenshots/')) {
717
+ // It's a local path - use it directly
718
+ img.src = s.dataUrl;
719
+ img.alt = `${label} screenshot`;
720
+ } else {
721
+ // No valid image - show placeholder with correct time
722
+ const placeholderText = `${label} ${formatMs(time)}`;
723
+ img.src = `https://placehold.co/420x256/1C2333/8B949E?text=${encodeURIComponent(placeholderText)}`;
724
+ img.alt = `${label} (placeholder)`;
725
+ }
726
+
727
+ img.addEventListener("click", () => {
728
+ if (s.dataUrl && (s.dataUrl.startsWith('data:image') || s.dataUrl.startsWith('http') || s.dataUrl.startsWith('/screenshots/'))) {
729
+ const fullSrc = s.dataUrl.startsWith('/screenshots/')
730
+ ? window.location.origin + s.dataUrl
731
+ : s.dataUrl;
732
+ openLightbox(fullSrc, `${routeKey} — ${label} @ ${formatMs(time)}`);
733
+ } else {
734
+ alert(`📸 ${label}\n⏱ Taken at: ${formatMs(time)}\n\n(Image not available)`);
735
+ }
736
+ });
737
+
738
+ const labelEl = document.createElement("div");
739
+ labelEl.className = "film-label";
740
+ labelEl.textContent = label;
741
+
742
+ const timeEl = document.createElement("div");
743
+ timeEl.className = "film-time";
744
+ timeEl.textContent = formatMs(time);
745
+
746
+ frame.append(img, labelEl, timeEl);
747
+ el.appendChild(frame);
748
+ });
749
+ }
750
+
751
+ function renderPagination(elId, current, total, onClick) {
752
+ const el = document.getElementById(elId);
753
+ if (!el || total <= 1) {
754
+ if (el) el.innerHTML = "";
755
+ return;
756
+ }
757
+
758
+ const wrap = document.createElement("div");
759
+ wrap.className = "pagination";
760
+
761
+ function makeBtn(label, page, disabled, active) {
762
+ const btn = document.createElement("button");
763
+ btn.className = "page-btn" + (active ? " active" : "");
764
+ btn.textContent = label;
765
+ if (disabled) {
766
+ btn.disabled = true;
767
+ } else {
768
+ btn.addEventListener("click", () => onClick(page));
769
+ }
770
+ return btn;
771
+ }
772
+
773
+ wrap.appendChild(makeBtn("Prev", current - 1, current === 1, false));
774
+
775
+ for (let p = 1; p <= total; p++) {
776
+ if (total > 7 && Math.abs(p - current) > 2 && p !== 1 && p !== total) {
777
+ if (p === 2 || p === total - 1) {
778
+ const dots = document.createElement("span");
779
+ dots.className = "page-info";
780
+ dots.textContent = "…";
781
+ wrap.appendChild(dots);
782
+ }
783
+ continue;
784
+ }
785
+ wrap.appendChild(makeBtn(String(p), p, false, p === current));
786
+ }
787
+
788
+ wrap.appendChild(makeBtn("Next", current + 1, current === total, false));
789
+
790
+ const info = document.createElement("span");
791
+ info.className = "page-info";
792
+ info.textContent = `${current} / ${total}`;
793
+ wrap.appendChild(info);
794
+
795
+ el.innerHTML = "";
796
+ el.appendChild(wrap);
797
+ }