quackscore 0.3.1 → 0.4.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.
Files changed (70) hide show
  1. package/README.md +20 -1
  2. package/dist/commands/create.js +2 -2
  3. package/dist/commands/create.js.map +1 -1
  4. package/dist/commands/mock-report.js +21 -21
  5. package/dist/commands/mock-report.js.map +1 -1
  6. package/dist/commands/stats.d.ts +7 -0
  7. package/dist/commands/stats.d.ts.map +1 -0
  8. package/dist/commands/stats.js +59 -0
  9. package/dist/commands/stats.js.map +1 -0
  10. package/dist/commands/update.js +2 -2
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/github/index.d.ts +2 -0
  13. package/dist/github/index.d.ts.map +1 -1
  14. package/dist/github/index.js +1 -0
  15. package/dist/github/index.js.map +1 -1
  16. package/dist/github/repository-stats.d.ts +8 -0
  17. package/dist/github/repository-stats.d.ts.map +1 -0
  18. package/dist/github/repository-stats.js +96 -0
  19. package/dist/github/repository-stats.js.map +1 -0
  20. package/dist/index.js +16 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/llm/analyzer.js +5 -5
  23. package/dist/llm/analyzer.js.map +1 -1
  24. package/dist/llm/character.js +3 -3
  25. package/dist/llm/character.js.map +1 -1
  26. package/dist/llm/prompts.d.ts.map +1 -1
  27. package/dist/llm/prompts.js +13 -1
  28. package/dist/llm/prompts.js.map +1 -1
  29. package/dist/report/generate.js +5 -5
  30. package/dist/report/generate.js.map +1 -1
  31. package/dist/report/index.d.ts +1 -0
  32. package/dist/report/index.d.ts.map +1 -1
  33. package/dist/report/index.js +1 -0
  34. package/dist/report/index.js.map +1 -1
  35. package/dist/report/repository-stats.d.ts +3 -0
  36. package/dist/report/repository-stats.d.ts.map +1 -0
  37. package/dist/report/repository-stats.js +650 -0
  38. package/dist/report/repository-stats.js.map +1 -0
  39. package/dist/scoring/index.d.ts +1 -0
  40. package/dist/scoring/index.d.ts.map +1 -1
  41. package/dist/scoring/index.js +1 -0
  42. package/dist/scoring/index.js.map +1 -1
  43. package/dist/scoring/points.d.ts.map +1 -1
  44. package/dist/scoring/points.js +5 -10
  45. package/dist/scoring/points.js.map +1 -1
  46. package/dist/scoring/repository-stats.d.ts +7 -0
  47. package/dist/scoring/repository-stats.d.ts.map +1 -0
  48. package/dist/scoring/repository-stats.js +120 -0
  49. package/dist/scoring/repository-stats.js.map +1 -0
  50. package/dist/shared/profile-summary.js +6 -6
  51. package/dist/shared/profile-summary.js.map +1 -1
  52. package/dist/shared/repository-stats.d.ts +51 -0
  53. package/dist/shared/repository-stats.d.ts.map +1 -0
  54. package/dist/shared/repository-stats.js +2 -0
  55. package/dist/shared/repository-stats.js.map +1 -0
  56. package/dist/shared/types.d.ts +1 -1
  57. package/dist/shared/types.d.ts.map +1 -1
  58. package/dist/storage/index.d.ts +1 -1
  59. package/dist/storage/index.d.ts.map +1 -1
  60. package/dist/storage/index.js +1 -1
  61. package/dist/storage/index.js.map +1 -1
  62. package/dist/storage/paths.d.ts +1 -0
  63. package/dist/storage/paths.d.ts.map +1 -1
  64. package/dist/storage/paths.js +8 -0
  65. package/dist/storage/paths.js.map +1 -1
  66. package/dist/storage/report.d.ts +1 -0
  67. package/dist/storage/report.d.ts.map +1 -1
  68. package/dist/storage/report.js +14 -1
  69. package/dist/storage/report.js.map +1 -1
  70. package/package.json +1 -1
@@ -0,0 +1,650 @@
1
+ import { formatDuration } from '../scoring/repository-stats.js';
2
+ function escapeHtml(value) {
3
+ return value
4
+ .replace(/&/g, '&')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;')
8
+ .replace(/'/g, '&#39;');
9
+ }
10
+ function serializeForScript(value) {
11
+ return JSON.stringify(value).replace(/</g, '\\u003c');
12
+ }
13
+ function formatNumber(value) {
14
+ return value.toLocaleString('en-US', { maximumFractionDigits: 1 });
15
+ }
16
+ export function generateRepositoryStatsHTML(data) {
17
+ const scopeLabel = data.team
18
+ ? `${data.org}/${data.repo} - ${data.team}`
19
+ : `${data.org}/${data.repo}`;
20
+ const generatedAt = new Date(data.generatedAt).toLocaleString('en-US', {
21
+ day: '2-digit',
22
+ month: 'short',
23
+ year: 'numeric',
24
+ hour: '2-digit',
25
+ minute: '2-digit',
26
+ });
27
+ return `<!DOCTYPE html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="UTF-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
+ <title>Quackscore Repository Stats - ${escapeHtml(scopeLabel)}</title>
33
+ <style>
34
+ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
35
+
36
+ :root {
37
+ color-scheme: dark;
38
+ --bg: #070707;
39
+ --panel: #111111;
40
+ --panel-soft: #171717;
41
+ --panel-strong: #1f1f1f;
42
+ --border: rgba(255,255,255,0.10);
43
+ --text: #f4f4f4;
44
+ --muted: #a3a3a3;
45
+ --amber: #f5b331;
46
+ --amber-soft: rgba(245,179,49,0.16);
47
+ --blue: #7da2ff;
48
+ --blue-soft: rgba(125,162,255,0.16);
49
+ --violet: #b380ff;
50
+ --green: #7dd3a7;
51
+ --danger: #ff8d8d;
52
+ }
53
+ * { box-sizing: border-box; }
54
+ body {
55
+ margin: 0;
56
+ min-height: 100vh;
57
+ font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
58
+ color: var(--text);
59
+ background: linear-gradient(180deg, #050505 0%, #101010 58%, #080808 100%);
60
+ }
61
+ button, input {
62
+ font: inherit;
63
+ }
64
+ .shell {
65
+ width: min(1180px, calc(100vw - 32px));
66
+ margin: 0 auto;
67
+ padding: 28px 0 44px;
68
+ }
69
+ .hero {
70
+ display: grid;
71
+ grid-template-columns: minmax(0, 1fr) auto;
72
+ gap: 18px;
73
+ align-items: end;
74
+ padding: 24px;
75
+ border: 1px solid rgba(245,179,49,0.18);
76
+ border-radius: 8px;
77
+ background: linear-gradient(135deg, rgba(31,31,31,0.98), rgba(12,12,12,0.98));
78
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
79
+ }
80
+ .eyebrow {
81
+ margin: 0 0 8px;
82
+ color: var(--muted);
83
+ font-family: "JetBrains Mono", ui-monospace, monospace;
84
+ font-size: 0.78rem;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0;
87
+ }
88
+ h1 {
89
+ margin: 0;
90
+ color: var(--amber);
91
+ font-family: "Cinzel", ui-serif, Georgia, serif;
92
+ font-size: clamp(2rem, 5vw, 4rem);
93
+ line-height: 1;
94
+ }
95
+ .hero-meta {
96
+ margin: 10px 0 0;
97
+ color: var(--muted);
98
+ line-height: 1.5;
99
+ }
100
+ .hero-count {
101
+ min-width: 150px;
102
+ padding: 14px 16px;
103
+ border: 1px solid var(--border);
104
+ border-radius: 8px;
105
+ background: rgba(255,255,255,0.04);
106
+ font-family: "JetBrains Mono", ui-monospace, monospace;
107
+ color: var(--muted);
108
+ }
109
+ .hero-count strong {
110
+ display: block;
111
+ color: var(--amber);
112
+ font-size: 1.8rem;
113
+ font-family: "Cinzel", ui-serif, Georgia, serif;
114
+ }
115
+ .filters {
116
+ display: grid;
117
+ grid-template-columns: minmax(0, 1fr) auto;
118
+ gap: 14px;
119
+ margin-top: 16px;
120
+ padding: 16px;
121
+ border: 1px solid var(--border);
122
+ border-radius: 8px;
123
+ background: var(--panel);
124
+ }
125
+ .range-buttons {
126
+ display: flex;
127
+ flex-wrap: wrap;
128
+ gap: 8px;
129
+ }
130
+ .range-btn,
131
+ .date-input {
132
+ min-height: 40px;
133
+ border: 1px solid rgba(255,255,255,0.12);
134
+ border-radius: 8px;
135
+ background: var(--panel-strong);
136
+ color: var(--text);
137
+ }
138
+ .range-btn {
139
+ padding: 0 14px;
140
+ cursor: pointer;
141
+ }
142
+ .range-btn:hover,
143
+ .range-btn.is-active {
144
+ border-color: rgba(245,179,49,0.42);
145
+ background: rgba(245,179,49,0.12);
146
+ color: #ffd98c;
147
+ }
148
+ .custom-range {
149
+ display: none;
150
+ grid-template-columns: repeat(2, minmax(150px, 1fr));
151
+ gap: 8px;
152
+ }
153
+ .custom-range.is-visible {
154
+ display: grid;
155
+ }
156
+ .date-input {
157
+ padding: 0 12px;
158
+ }
159
+ .date-input::-webkit-calendar-picker-indicator {
160
+ filter: invert(0.8) opacity(0.7);
161
+ }
162
+ .summary-grid {
163
+ display: grid;
164
+ grid-template-columns: repeat(5, minmax(0, 1fr));
165
+ gap: 12px;
166
+ margin-top: 16px;
167
+ }
168
+ .metric {
169
+ min-height: 112px;
170
+ padding: 16px;
171
+ border: 1px solid var(--border);
172
+ border-radius: 8px;
173
+ background: linear-gradient(180deg, var(--panel), var(--panel-soft));
174
+ }
175
+ .metric span {
176
+ display: block;
177
+ color: var(--muted);
178
+ font-size: 0.78rem;
179
+ font-family: "JetBrains Mono", ui-monospace, monospace;
180
+ text-transform: uppercase;
181
+ }
182
+ .metric strong {
183
+ display: block;
184
+ margin-top: 12px;
185
+ color: var(--text);
186
+ font-size: clamp(1.45rem, 3vw, 2rem);
187
+ }
188
+ .metric.accent strong { color: var(--amber); }
189
+ .metric.blue strong { color: var(--blue); }
190
+ .metric.green strong { color: var(--green); }
191
+ .content-grid {
192
+ display: grid;
193
+ grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
194
+ gap: 16px;
195
+ margin-top: 16px;
196
+ }
197
+ .section {
198
+ border: 1px solid var(--border);
199
+ border-radius: 8px;
200
+ background: var(--panel);
201
+ overflow: hidden;
202
+ }
203
+ .section-header {
204
+ display: flex;
205
+ justify-content: space-between;
206
+ align-items: center;
207
+ gap: 12px;
208
+ padding: 16px 18px;
209
+ border-bottom: 1px solid rgba(255,255,255,0.08);
210
+ }
211
+ .section-header h2 {
212
+ margin: 0;
213
+ font-size: 1rem;
214
+ }
215
+ .section-header span {
216
+ color: var(--muted);
217
+ font-size: 0.86rem;
218
+ }
219
+ .chart {
220
+ padding: 18px;
221
+ }
222
+ .bar-row,
223
+ .trend-row {
224
+ display: grid;
225
+ grid-template-columns: 94px minmax(0, 1fr) 72px;
226
+ gap: 12px;
227
+ align-items: center;
228
+ min-height: 34px;
229
+ color: var(--muted);
230
+ font-size: 0.86rem;
231
+ }
232
+ .bar-track,
233
+ .trend-track {
234
+ height: 14px;
235
+ border-radius: 8px;
236
+ background: rgba(255,255,255,0.07);
237
+ overflow: hidden;
238
+ }
239
+ .bar-fill,
240
+ .approval-fill,
241
+ .merge-fill {
242
+ height: 100%;
243
+ border-radius: 8px;
244
+ }
245
+ .bar-fill {
246
+ background: linear-gradient(90deg, var(--amber), #ffe0a0);
247
+ }
248
+ .trend-track {
249
+ display: grid;
250
+ grid-template-columns: 1fr;
251
+ }
252
+ .approval-fill {
253
+ background: linear-gradient(90deg, var(--blue), #c6d5ff);
254
+ }
255
+ .merge-fill {
256
+ background: linear-gradient(90deg, var(--violet), #ddc8ff);
257
+ }
258
+ .trend-legend {
259
+ display: flex;
260
+ gap: 12px;
261
+ color: var(--muted);
262
+ font-size: 0.82rem;
263
+ }
264
+ .legend-dot {
265
+ display: inline-block;
266
+ width: 9px;
267
+ height: 9px;
268
+ margin-right: 6px;
269
+ border-radius: 999px;
270
+ }
271
+ .legend-dot.approval { background: var(--blue); }
272
+ .legend-dot.merge { background: var(--violet); }
273
+ .table-wrap {
274
+ overflow-x: auto;
275
+ }
276
+ table {
277
+ width: 100%;
278
+ border-collapse: collapse;
279
+ min-width: 620px;
280
+ }
281
+ th,
282
+ td {
283
+ padding: 13px 16px;
284
+ border-bottom: 1px solid rgba(255,255,255,0.07);
285
+ text-align: left;
286
+ }
287
+ th {
288
+ color: var(--muted);
289
+ font-family: "JetBrains Mono", ui-monospace, monospace;
290
+ font-size: 0.74rem;
291
+ text-transform: uppercase;
292
+ }
293
+ td {
294
+ color: var(--text);
295
+ }
296
+ td.numeric,
297
+ th.numeric {
298
+ text-align: right;
299
+ }
300
+ .mono {
301
+ font-family: "JetBrains Mono", ui-monospace, monospace;
302
+ }
303
+ .empty {
304
+ padding: 24px;
305
+ color: var(--muted);
306
+ text-align: center;
307
+ }
308
+ @media (max-width: 920px) {
309
+ .hero,
310
+ .filters,
311
+ .content-grid {
312
+ grid-template-columns: 1fr;
313
+ }
314
+ .summary-grid {
315
+ grid-template-columns: repeat(2, minmax(0, 1fr));
316
+ }
317
+ }
318
+ @media (max-width: 560px) {
319
+ .shell {
320
+ width: min(100vw - 20px, 1180px);
321
+ padding-top: 10px;
322
+ }
323
+ .hero,
324
+ .filters,
325
+ .metric,
326
+ .section-header,
327
+ .chart {
328
+ padding: 12px;
329
+ }
330
+ .summary-grid {
331
+ grid-template-columns: 1fr;
332
+ }
333
+ .bar-row,
334
+ .trend-row {
335
+ grid-template-columns: 72px minmax(0, 1fr) 54px;
336
+ gap: 8px;
337
+ }
338
+ table {
339
+ min-width: 560px;
340
+ }
341
+ }
342
+ </style>
343
+ </head>
344
+ <body>
345
+ <main class="shell">
346
+ <section class="hero">
347
+ <div>
348
+ <p class="eyebrow">Repository stats${data.team ? ' / Team authors' : ''}</p>
349
+ <h1>${escapeHtml(scopeLabel)}</h1>
350
+ <p class="hero-meta">Generated ${escapeHtml(generatedAt)}. Metrics update instantly when the date range changes.</p>
351
+ </div>
352
+ <div class="hero-count"><strong data-count>${data.summary.mergedPRs}</strong> merged PRs</div>
353
+ </section>
354
+
355
+ <section class="filters" aria-label="Report filters">
356
+ <div class="range-buttons" data-range-buttons>
357
+ <button class="range-btn is-active" type="button" data-range="all">All time</button>
358
+ <button class="range-btn" type="button" data-range="last_month">Last month</button>
359
+ <button class="range-btn" type="button" data-range="last_3_months">Last 3 months</button>
360
+ <button class="range-btn" type="button" data-range="last_6_months">Last 6 months</button>
361
+ <button class="range-btn" type="button" data-range="last_year">Last year</button>
362
+ <button class="range-btn" type="button" data-range="custom">Custom</button>
363
+ </div>
364
+ <div class="custom-range" data-custom-range>
365
+ <input class="date-input" type="date" aria-label="From date" data-from>
366
+ <input class="date-input" type="date" aria-label="To date" data-to>
367
+ </div>
368
+ </section>
369
+
370
+ <section class="summary-grid" aria-label="Summary metrics">
371
+ <div class="metric accent"><span>Merged PRs</span><strong data-metric="mergedPRs">${formatNumber(data.summary.mergedPRs)}</strong></div>
372
+ <div class="metric green"><span>Approved PRs</span><strong data-metric="approvedPRs">${formatNumber(data.summary.approvedPRs)}</strong></div>
373
+ <div class="metric blue"><span>Avg approval</span><strong data-metric="averageApprovalHours">${escapeHtml(formatDuration(data.summary.averageApprovalHours))}</strong></div>
374
+ <div class="metric"><span>Avg merge</span><strong data-metric="averageMergeHours">${escapeHtml(formatDuration(data.summary.averageMergeHours))}</strong></div>
375
+ <div class="metric"><span>Avg comments</span><strong data-metric="averageComments">${formatNumber(data.summary.averageComments)}</strong></div>
376
+ </section>
377
+
378
+ <section class="content-grid">
379
+ <div class="section">
380
+ <div class="section-header">
381
+ <h2>Weekly Contributions</h2>
382
+ <span>Merged PRs per week</span>
383
+ </div>
384
+ <div class="chart" data-weekly-chart></div>
385
+ </div>
386
+ <div class="section">
387
+ <div class="section-header">
388
+ <h2>Timing Trends</h2>
389
+ <div class="trend-legend">
390
+ <span><i class="legend-dot approval"></i>Approval</span>
391
+ <span><i class="legend-dot merge"></i>Merge</span>
392
+ </div>
393
+ </div>
394
+ <div class="chart" data-timing-chart></div>
395
+ </div>
396
+ </section>
397
+
398
+ <section class="content-grid">
399
+ <div class="section">
400
+ <div class="section-header">
401
+ <h2>Author Stats</h2>
402
+ <span>PR ownership and discussion load</span>
403
+ </div>
404
+ <div class="table-wrap">
405
+ <table>
406
+ <thead><tr><th>Author</th><th class="numeric">PRs</th><th class="numeric">Approved</th><th class="numeric">Avg approval</th><th class="numeric">Avg comments</th></tr></thead>
407
+ <tbody data-author-table></tbody>
408
+ </table>
409
+ </div>
410
+ </div>
411
+ <div class="section">
412
+ <div class="section-header">
413
+ <h2>Approver Speed</h2>
414
+ <span>Fastest reviewers first</span>
415
+ </div>
416
+ <div class="table-wrap">
417
+ <table>
418
+ <thead><tr><th>Approver</th><th class="numeric">Approvals</th><th class="numeric">Avg approval</th></tr></thead>
419
+ <tbody data-approver-table></tbody>
420
+ </table>
421
+ </div>
422
+ </div>
423
+ </section>
424
+ </main>
425
+
426
+ <script id="repository-stats-data" type="application/json">${serializeForScript(data)}</script>
427
+ <script>
428
+ (function() {
429
+ const HOUR_MS = 60 * 60 * 1000;
430
+ const DAY_MS = 24 * HOUR_MS;
431
+ const dataElement = document.getElementById('repository-stats-data');
432
+ if (!dataElement) return;
433
+ const report = JSON.parse(dataElement.textContent || '{}');
434
+ const prs = Array.isArray(report.pullRequests) ? report.pullRequests : [];
435
+ const generatedAtMs = Date.parse(report.generatedAt) || Date.now();
436
+ let range = 'all';
437
+
438
+ const buttons = Array.from(document.querySelectorAll('[data-range]'));
439
+ const customRange = document.querySelector('[data-custom-range]');
440
+ const fromInput = document.querySelector('[data-from]');
441
+ const toInput = document.querySelector('[data-to]');
442
+ const count = document.querySelector('[data-count]');
443
+ const weeklyChart = document.querySelector('[data-weekly-chart]');
444
+ const timingChart = document.querySelector('[data-timing-chart]');
445
+ const authorTable = document.querySelector('[data-author-table]');
446
+ const approverTable = document.querySelector('[data-approver-table]');
447
+
448
+ const escapeHtml = (value) => String(value)
449
+ .replace(/&/g, '&amp;')
450
+ .replace(/</g, '&lt;')
451
+ .replace(/>/g, '&gt;')
452
+ .replace(/"/g, '&quot;')
453
+ .replace(/'/g, '&#39;');
454
+
455
+ const formatNumber = (value) => Number(value || 0).toLocaleString('en-US', { maximumFractionDigits: 1 });
456
+ const formatDuration = (hours) => {
457
+ if (hours == null || Number.isNaN(hours)) return 'N/A';
458
+ if (hours < 1) return Math.round(hours * 60) + 'm';
459
+ if (hours < 48) return hours.toFixed(1) + 'h';
460
+ return (hours / 24).toFixed(1) + 'd';
461
+ };
462
+ const hoursBetween = (start, end) => {
463
+ const startMs = Date.parse(start);
464
+ const endMs = Date.parse(end);
465
+ if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return null;
466
+ return (endMs - startMs) / HOUR_MS;
467
+ };
468
+ const average = (values) => values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
469
+ const getWeekStart = (value) => {
470
+ const date = new Date(value);
471
+ const day = date.getUTCDay();
472
+ const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
473
+ const monday = new Date(date);
474
+ monday.setUTCDate(diff);
475
+ monday.setUTCHours(0, 0, 0, 0);
476
+ return monday.toISOString().slice(0, 10);
477
+ };
478
+ const firstApprovalHours = (pr) => {
479
+ const approvals = Array.isArray(pr.approvals) ? [...pr.approvals] : [];
480
+ approvals.sort((left, right) => Date.parse(left.submittedAt) - Date.parse(right.submittedAt));
481
+ return approvals[0] ? hoursBetween(pr.createdAt, approvals[0].submittedAt) : null;
482
+ };
483
+
484
+ const cutoffFor = () => {
485
+ if (range === 'all' || range === 'custom') return null;
486
+ const days = {
487
+ last_month: 30,
488
+ last_3_months: 90,
489
+ last_6_months: 180,
490
+ last_year: 365,
491
+ }[range];
492
+ return days ? generatedAtMs - days * DAY_MS : null;
493
+ };
494
+
495
+ const filteredPRs = () => {
496
+ const cutoff = cutoffFor();
497
+ const fromMs = range === 'custom' && fromInput && fromInput.value ? Date.parse(fromInput.value) : null;
498
+ const toMs = range === 'custom' && toInput && toInput.value ? Date.parse(toInput.value + 'T23:59:59.999') : null;
499
+ return prs.filter((pr) => {
500
+ const mergedAt = Date.parse(pr.mergedAt);
501
+ if (Number.isNaN(mergedAt)) return false;
502
+ if (cutoff != null && mergedAt < cutoff) return false;
503
+ if (fromMs != null && mergedAt < fromMs) return false;
504
+ if (toMs != null && mergedAt > toMs) return false;
505
+ return true;
506
+ });
507
+ };
508
+
509
+ const buildStats = (items) => {
510
+ const mergeHours = items.map((pr) => hoursBetween(pr.createdAt, pr.mergedAt)).filter((value) => value != null);
511
+ const approvalHours = items.map(firstApprovalHours).filter((value) => value != null);
512
+ const totalComments = items.reduce((sum, pr) => sum + (pr.issueComments || 0) + (pr.reviewComments || 0), 0);
513
+ const weekMap = new Map();
514
+ const trendMap = new Map();
515
+ const authorMap = new Map();
516
+ const approverMap = new Map();
517
+
518
+ items.forEach((pr) => {
519
+ const week = getWeekStart(pr.mergedAt);
520
+ weekMap.set(week, (weekMap.get(week) || 0) + 1);
521
+ const trend = trendMap.get(week) || { approval: [], merge: [] };
522
+ const firstApproval = firstApprovalHours(pr);
523
+ const mergeTime = hoursBetween(pr.createdAt, pr.mergedAt);
524
+ if (firstApproval != null) trend.approval.push(firstApproval);
525
+ if (mergeTime != null) trend.merge.push(mergeTime);
526
+ trendMap.set(week, trend);
527
+
528
+ const authorItems = authorMap.get(pr.author) || [];
529
+ authorItems.push(pr);
530
+ authorMap.set(pr.author, authorItems);
531
+
532
+ (pr.approvals || []).forEach((approval) => {
533
+ const approvalTime = hoursBetween(pr.createdAt, approval.submittedAt);
534
+ if (approvalTime == null) return;
535
+ const approverItems = approverMap.get(approval.approver) || [];
536
+ approverItems.push(approvalTime);
537
+ approverMap.set(approval.approver, approverItems);
538
+ });
539
+ });
540
+
541
+ const weekly = Array.from(weekMap.entries()).map(([week, pullRequests]) => ({ week, pullRequests })).sort((left, right) => left.week.localeCompare(right.week));
542
+ const trends = Array.from(trendMap.entries()).map(([week, values]) => ({
543
+ week,
544
+ approval: average(values.approval),
545
+ merge: average(values.merge),
546
+ })).sort((left, right) => left.week.localeCompare(right.week));
547
+ const authors = Array.from(authorMap.entries()).map(([author, authorPRs]) => {
548
+ const authorApprovals = authorPRs.map(firstApprovalHours).filter((value) => value != null);
549
+ const authorComments = authorPRs.reduce((sum, pr) => sum + (pr.issueComments || 0) + (pr.reviewComments || 0), 0);
550
+ return {
551
+ author,
552
+ pullRequests: authorPRs.length,
553
+ approvedPRs: authorApprovals.length,
554
+ averageApprovalHours: average(authorApprovals),
555
+ averageComments: authorPRs.length ? authorComments / authorPRs.length : 0,
556
+ };
557
+ }).sort((left, right) => right.pullRequests - left.pullRequests || String(left.author).localeCompare(String(right.author)));
558
+ const approvers = Array.from(approverMap.entries()).map(([approver, times]) => ({
559
+ approver,
560
+ approvals: times.length,
561
+ averageApprovalHours: average(times) || 0,
562
+ })).sort((left, right) => left.averageApprovalHours - right.averageApprovalHours || right.approvals - left.approvals || String(left.approver).localeCompare(String(right.approver)));
563
+
564
+ return {
565
+ summary: {
566
+ mergedPRs: items.length,
567
+ approvedPRs: approvalHours.length,
568
+ averageApprovalHours: average(approvalHours),
569
+ averageMergeHours: average(mergeHours),
570
+ averageComments: items.length ? totalComments / items.length : 0,
571
+ },
572
+ weekly,
573
+ trends,
574
+ authors,
575
+ approvers,
576
+ };
577
+ };
578
+
579
+ const renderBars = (items) => {
580
+ if (!weeklyChart) return;
581
+ if (!items.length) {
582
+ weeklyChart.innerHTML = '<div class="empty">No merged pull requests in this date range.</div>';
583
+ return;
584
+ }
585
+ const max = Math.max(...items.map((item) => item.pullRequests), 1);
586
+ weeklyChart.innerHTML = items.map((item) => {
587
+ const width = Math.max(5, Math.round(item.pullRequests / max * 100));
588
+ return '<div class="bar-row"><span class="mono">' + escapeHtml(item.week) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + width + '%"></div></div><strong>' + item.pullRequests + '</strong></div>';
589
+ }).join('');
590
+ };
591
+
592
+ const renderTrends = (items) => {
593
+ if (!timingChart) return;
594
+ if (!items.length) {
595
+ timingChart.innerHTML = '<div class="empty">No timing data in this date range.</div>';
596
+ return;
597
+ }
598
+ const max = Math.max(...items.flatMap((item) => [item.approval || 0, item.merge || 0]), 1);
599
+ timingChart.innerHTML = items.map((item) => {
600
+ const approvalWidth = item.approval == null ? 0 : Math.max(4, Math.round(item.approval / max * 100));
601
+ const mergeWidth = item.merge == null ? 0 : Math.max(4, Math.round(item.merge / max * 100));
602
+ return '<div class="trend-row"><span class="mono">' + escapeHtml(item.week) + '</span><div><div class="trend-track"><div class="approval-fill" style="width:' + approvalWidth + '%"></div></div><div class="trend-track" style="margin-top:6px"><div class="merge-fill" style="width:' + mergeWidth + '%"></div></div></div><strong>' + escapeHtml(formatDuration(item.approval)) + ' / ' + escapeHtml(formatDuration(item.merge)) + '</strong></div>';
603
+ }).join('');
604
+ };
605
+
606
+ const renderAuthors = (items) => {
607
+ if (!authorTable) return;
608
+ authorTable.innerHTML = items.length
609
+ ? items.map((item) => '<tr><td class="mono">' + escapeHtml(item.author) + '</td><td class="numeric">' + item.pullRequests + '</td><td class="numeric">' + item.approvedPRs + '</td><td class="numeric">' + escapeHtml(formatDuration(item.averageApprovalHours)) + '</td><td class="numeric">' + formatNumber(item.averageComments) + '</td></tr>').join('')
610
+ : '<tr><td colspan="5"><div class="empty">No authors in this date range.</div></td></tr>';
611
+ };
612
+
613
+ const renderApprovers = (items) => {
614
+ if (!approverTable) return;
615
+ approverTable.innerHTML = items.length
616
+ ? items.map((item) => '<tr><td class="mono">' + escapeHtml(item.approver) + '</td><td class="numeric">' + item.approvals + '</td><td class="numeric">' + escapeHtml(formatDuration(item.averageApprovalHours)) + '</td></tr>').join('')
617
+ : '<tr><td colspan="3"><div class="empty">No approvals in this date range.</div></td></tr>';
618
+ };
619
+
620
+ const render = () => {
621
+ const stats = buildStats(filteredPRs());
622
+ if (count) count.textContent = String(stats.summary.mergedPRs);
623
+ document.querySelector('[data-metric="mergedPRs"]').textContent = formatNumber(stats.summary.mergedPRs);
624
+ document.querySelector('[data-metric="approvedPRs"]').textContent = formatNumber(stats.summary.approvedPRs);
625
+ document.querySelector('[data-metric="averageApprovalHours"]').textContent = formatDuration(stats.summary.averageApprovalHours);
626
+ document.querySelector('[data-metric="averageMergeHours"]').textContent = formatDuration(stats.summary.averageMergeHours);
627
+ document.querySelector('[data-metric="averageComments"]').textContent = formatNumber(stats.summary.averageComments);
628
+ renderBars(stats.weekly);
629
+ renderTrends(stats.trends);
630
+ renderAuthors(stats.authors);
631
+ renderApprovers(stats.approvers);
632
+ };
633
+
634
+ buttons.forEach((button) => {
635
+ button.addEventListener('click', () => {
636
+ range = button.getAttribute('data-range') || 'all';
637
+ buttons.forEach((item) => item.classList.toggle('is-active', item === button));
638
+ if (customRange) customRange.classList.toggle('is-visible', range === 'custom');
639
+ render();
640
+ });
641
+ });
642
+ if (fromInput) fromInput.addEventListener('change', render);
643
+ if (toInput) toInput.addEventListener('change', render);
644
+ render();
645
+ })();
646
+ </script>
647
+ </body>
648
+ </html>`;
649
+ }
650
+ //# sourceMappingURL=repository-stats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repository-stats.js","sourceRoot":"","sources":["../../src/report/repository-stats.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
1
  export { calculatePRPoints, calculateLevel, LEVEL_THRESHOLDS, MAX_LEVEL } from './points.js';
2
2
  export { aggregateStats } from './stats.js';
3
+ export { aggregateRepositoryStats, formatDuration } from './repository-stats.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,wBAAwB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC"}
@@ -1,3 +1,4 @@
1
1
  export { calculatePRPoints, calculateLevel, LEVEL_THRESHOLDS, MAX_LEVEL } from './points.js';
2
2
  export { aggregateStats } from './stats.js';
3
+ export { aggregateRepositoryStats, formatDuration } from './repository-stats.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7F,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,wBAAwB,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"points.d.ts","sourceRoot":"","sources":["../../src/scoring/points.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAEjD,eAAO,MAAM,SAAS,MAAM,CAAC;AAqB7B,eAAO,MAAM,gBAAgB,UAAoB,CAAC;AAElD,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAQpD;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAWhG"}
1
+ {"version":3,"file":"points.d.ts","sourceRoot":"","sources":["../../src/scoring/points.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAEjD,eAAO,MAAM,SAAS,MAAM,CAAC;AAqB7B,eAAO,MAAM,gBAAgB,UAAoB,CAAC;AAElD,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAGpD;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAWhG"}