quackscore 0.2.5 → 0.3.1

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 (92) hide show
  1. package/dist/commands/create.d.ts +1 -0
  2. package/dist/commands/create.d.ts.map +1 -1
  3. package/dist/commands/create.js +26 -10
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +19 -0
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/leaderboard.d.ts +5 -1
  9. package/dist/commands/leaderboard.d.ts.map +1 -1
  10. package/dist/commands/leaderboard.js +57 -29
  11. package/dist/commands/leaderboard.js.map +1 -1
  12. package/dist/commands/profile-options.d.ts +24 -0
  13. package/dist/commands/profile-options.d.ts.map +1 -0
  14. package/dist/commands/profile-options.js +38 -0
  15. package/dist/commands/profile-options.js.map +1 -0
  16. package/dist/commands/remove.d.ts +5 -1
  17. package/dist/commands/remove.d.ts.map +1 -1
  18. package/dist/commands/remove.js +14 -5
  19. package/dist/commands/remove.js.map +1 -1
  20. package/dist/commands/show.d.ts +5 -1
  21. package/dist/commands/show.d.ts.map +1 -1
  22. package/dist/commands/show.js +12 -3
  23. package/dist/commands/show.js.map +1 -1
  24. package/dist/commands/update-summary.d.ts +2 -0
  25. package/dist/commands/update-summary.d.ts.map +1 -1
  26. package/dist/commands/update-summary.js +12 -3
  27. package/dist/commands/update-summary.js.map +1 -1
  28. package/dist/commands/update.d.ts.map +1 -1
  29. package/dist/commands/update.js +148 -68
  30. package/dist/commands/update.js.map +1 -1
  31. package/dist/config/types.d.ts +1 -1
  32. package/dist/config/types.d.ts.map +1 -1
  33. package/dist/config/types.js +14 -0
  34. package/dist/config/types.js.map +1 -1
  35. package/dist/github/client.d.ts +5 -5
  36. package/dist/github/client.d.ts.map +1 -1
  37. package/dist/github/client.js +157 -57
  38. package/dist/github/client.js.map +1 -1
  39. package/dist/index.js +32 -7
  40. package/dist/index.js.map +1 -1
  41. package/dist/llm/analyzer.d.ts +3 -3
  42. package/dist/llm/analyzer.d.ts.map +1 -1
  43. package/dist/llm/analyzer.js.map +1 -1
  44. package/dist/llm/character.d.ts +2 -2
  45. package/dist/llm/character.d.ts.map +1 -1
  46. package/dist/llm/character.js.map +1 -1
  47. package/dist/llm/client.d.ts +2 -2
  48. package/dist/llm/client.d.ts.map +1 -1
  49. package/dist/llm/client.js +1 -1
  50. package/dist/llm/client.js.map +1 -1
  51. package/dist/llm/providers.d.ts +3 -3
  52. package/dist/llm/providers.d.ts.map +1 -1
  53. package/dist/llm/providers.js +25 -6
  54. package/dist/llm/providers.js.map +1 -1
  55. package/dist/report/generate.d.ts.map +1 -1
  56. package/dist/report/generate.js +4 -2
  57. package/dist/report/generate.js.map +1 -1
  58. package/dist/report/leaderboard.d.ts +5 -1
  59. package/dist/report/leaderboard.d.ts.map +1 -1
  60. package/dist/report/leaderboard.js +611 -865
  61. package/dist/report/leaderboard.js.map +1 -1
  62. package/dist/shared/profile-scope.d.ts +19 -0
  63. package/dist/shared/profile-scope.d.ts.map +1 -0
  64. package/dist/shared/profile-scope.js +101 -0
  65. package/dist/shared/profile-scope.js.map +1 -0
  66. package/dist/shared/types.d.ts +28 -0
  67. package/dist/shared/types.d.ts.map +1 -1
  68. package/dist/shared/ui.d.ts +1 -0
  69. package/dist/shared/ui.d.ts.map +1 -1
  70. package/dist/shared/ui.js +3 -0
  71. package/dist/shared/ui.js.map +1 -1
  72. package/dist/storage/index.d.ts +1 -1
  73. package/dist/storage/index.d.ts.map +1 -1
  74. package/dist/storage/index.js +1 -1
  75. package/dist/storage/index.js.map +1 -1
  76. package/dist/storage/leaderboard.d.ts +3 -2
  77. package/dist/storage/leaderboard.d.ts.map +1 -1
  78. package/dist/storage/leaderboard.js +80 -9
  79. package/dist/storage/leaderboard.js.map +1 -1
  80. package/dist/storage/paths.d.ts +5 -0
  81. package/dist/storage/paths.d.ts.map +1 -1
  82. package/dist/storage/paths.js +13 -0
  83. package/dist/storage/paths.js.map +1 -1
  84. package/dist/storage/report.d.ts +4 -2
  85. package/dist/storage/report.d.ts.map +1 -1
  86. package/dist/storage/report.js +25 -8
  87. package/dist/storage/report.js.map +1 -1
  88. package/dist/storage/user.d.ts +2 -1
  89. package/dist/storage/user.d.ts.map +1 -1
  90. package/dist/storage/user.js +27 -10
  91. package/dist/storage/user.js.map +1 -1
  92. package/package.json +13 -6
@@ -10,9 +10,23 @@ const DISPLAY_LABELS = {
10
10
  security: 'Security',
11
11
  sre: 'SRE',
12
12
  };
13
- const PROFILES_PER_PAGE = 50;
13
+ function normalizeTeamKey(value) {
14
+ return String(value ?? '').trim().toLowerCase();
15
+ }
16
+ function collectTeams(profiles) {
17
+ const labels = new Map();
18
+ for (const profile of profiles) {
19
+ for (const team of profile.teams ?? []) {
20
+ const key = normalizeTeamKey(team);
21
+ if (!key || labels.has(key))
22
+ continue;
23
+ labels.set(key, team);
24
+ }
25
+ }
26
+ return [...labels.values()].sort((left, right) => left.localeCompare(right));
27
+ }
14
28
  function escapeHtml(value) {
15
- return value
29
+ return String(value ?? '')
16
30
  .replace(/&/g, '&')
17
31
  .replace(/</g, '&lt;')
18
32
  .replace(/>/g, '&gt;')
@@ -24,158 +38,18 @@ function formatLabel(value) {
24
38
  return 'Unspecified';
25
39
  return DISPLAY_LABELS[value] ?? value.split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
26
40
  }
27
- function compactText(value, maxLength = 260) {
28
- if (!value)
29
- return 'Summary not available yet.';
30
- const normalized = value.replace(/\s+/g, ' ').trim();
31
- if (normalized.length <= maxLength)
32
- return normalized;
33
- return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
34
- }
35
- function formatDate(value) {
36
- if (!value)
37
- return 'N/A';
38
- const parsed = Date.parse(value);
39
- if (Number.isNaN(parsed))
40
- return 'N/A';
41
- return new Date(parsed).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' });
42
- }
43
- function calculateLevel(totalPoints) {
44
- let level = 1;
45
- for (let i = 1; i < LEVEL_THRESHOLDS.length; i++) {
46
- if (totalPoints >= LEVEL_THRESHOLDS[i]) {
47
- level = i + 1;
48
- }
49
- else {
50
- return level;
51
- }
52
- }
53
- return MAX_LEVEL;
54
- }
55
- function compareEntries(left, right) {
56
- return right.totalPoints - left.totalPoints
57
- || right.analyzedPRs - left.analyzedPRs
58
- || Date.parse(right.lastActivityAt ?? right.lastUpdated) - Date.parse(left.lastActivityAt ?? left.lastUpdated)
59
- || left.username.localeCompare(right.username);
60
- }
61
- function buildEntry(profile) {
62
- if (profile.contributions.length === 0) {
63
- return {
64
- storageKey: profile.storageKey,
65
- username: profile.username,
66
- totalPoints: profile.totalPoints,
67
- level: profile.level,
68
- lastUpdated: profile.lastUpdated,
69
- lastActivityAt: profile.lastActivityAt,
70
- analyzedPRs: profile.analyzedPRs,
71
- primaryArea: profile.primaryArea,
72
- title: profile.title,
73
- summary: profile.summary,
74
- reportPath: profile.reportPath,
75
- };
76
- }
77
- const areaTotals = profile.contributions.reduce((totals, contribution) => {
78
- for (const area of contribution.areas) {
79
- totals[area] = (totals[area] || 0) + contribution.points;
80
- }
81
- return totals;
82
- }, {});
83
- const totalPoints = profile.contributions.reduce((sum, contribution) => sum + contribution.points, 0);
84
- const primaryArea = Object.entries(areaTotals).sort(([, left], [, right]) => right - left)[0]?.[0];
85
- const lastActivityAt = profile.contributions.reduce((latest, contribution) => {
86
- if (!latest)
87
- return contribution.mergedAt;
88
- return Date.parse(contribution.mergedAt) > Date.parse(latest) ? contribution.mergedAt : latest;
89
- }, undefined);
90
- return {
91
- storageKey: profile.storageKey,
92
- username: profile.username,
93
- totalPoints,
94
- level: calculateLevel(totalPoints),
95
- lastUpdated: profile.lastUpdated,
96
- lastActivityAt,
97
- analyzedPRs: profile.contributions.length,
98
- primaryArea,
99
- title: profile.title,
100
- summary: profile.summary,
101
- reportPath: profile.reportPath,
102
- };
103
- }
104
- function renderEntry(entry, index, isOpen) {
105
- const preview = compactText(entry.summary, 170);
106
- const fullSummary = compactText(entry.summary, 520);
107
- const reportAction = entry.reportPath
108
- ? `<a class="action-link" href="file:///${entry.reportPath.replace(/\\/g, '/')}" target="_blank" rel="noopener noreferrer">Open report</a>`
109
- : '<span class="action-link disabled">Report missing</span>';
110
- return `<details class="entry"${isOpen ? ' open' : ''}>
111
- <summary class="entry-summary">
112
- <div class="rank">${index + 1}</div>
113
- <div class="entry-main">
114
- <div class="entry-top">
115
- <div>
116
- <h2>${escapeHtml(entry.username)}</h2>
117
- <div class="subtitle">${escapeHtml(entry.title ?? 'Generated profile')}</div>
118
- </div>
119
- <div class="xp-block">
120
- <strong>${entry.totalPoints.toLocaleString()} XP</strong>
121
- <span>Level ${entry.level}</span>
122
- </div>
123
- </div>
124
- <div class="meta-row">
125
- <span class="meta-pill">${entry.analyzedPRs} PRs</span>
126
- <span class="meta-pill">${escapeHtml(formatLabel(entry.primaryArea))}</span>
127
- <span class="meta-pill">Last activity ${escapeHtml(formatDate(entry.lastActivityAt))}</span>
128
- </div>
129
- <p class="summary preview">${escapeHtml(preview)}</p>
130
- </div>
131
- <div class="entry-side">
132
- ${reportAction}
133
- <span class="expand-hint">More</span>
134
- </div>
135
- </summary>
136
- <div class="entry-details">
137
- <p class="summary full">${escapeHtml(fullSummary)}</p>
138
- <div class="detail-grid">
139
- <div class="detail-box">
140
- <span class="detail-label">Profile</span>
141
- <strong>${escapeHtml(entry.title ?? 'Generated profile')}</strong>
142
- </div>
143
- <div class="detail-box">
144
- <span class="detail-label">Specialization</span>
145
- <strong>${escapeHtml(formatLabel(entry.primaryArea))}</strong>
146
- </div>
147
- <div class="detail-box">
148
- <span class="detail-label">Scope</span>
149
- <strong>${escapeHtml(entry.storageKey)}</strong>
150
- </div>
151
- </div>
152
- </div>
153
- </details>`;
154
- }
155
- function renderEmptyState(message = 'No generated profiles found in ~/.quackscore yet.') {
156
- return `<div class="empty">${escapeHtml(message)}</div>`;
157
- }
158
- function renderFilterDropdown(id, dataAttribute, selectedLabel, options) {
159
- return `<div class="filter-dropdown" data-filter-dropdown>
160
- <input type="hidden" id="${escapeHtml(id)}" value="${escapeHtml(options[0]?.value ?? '')}" ${dataAttribute}>
161
- <button class="filter-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="${escapeHtml(id)}-menu" data-filter-trigger>
162
- <span class="filter-trigger-label" data-filter-label>${escapeHtml(selectedLabel)}</span>
163
- </button>
164
- <div class="filter-menu" id="${escapeHtml(id)}-menu" role="listbox" tabindex="-1" hidden data-filter-menu>
165
- ${options.map((option, index) => `<button class="filter-option${index === 0 ? ' is-selected' : ''}" type="button" role="option" data-filter-option data-value="${escapeHtml(option.value)}" aria-selected="${index === 0 ? 'true' : 'false'}">${escapeHtml(option.label)}</button>`).join('')}
166
- </div>
167
- </div>`;
168
- }
169
41
  function serializeForScript(value) {
170
42
  return JSON.stringify(value).replace(/</g, '\\u003c');
171
43
  }
172
- export function generateLeaderboardHTML(profiles) {
173
- const initialEntries = profiles.map(buildEntry).sort(compareEntries);
174
- const initialRows = initialEntries.length
175
- ? initialEntries.slice(0, PROFILES_PER_PAGE).map((entry, index) => renderEntry(entry, index, false)).join('')
176
- : renderEmptyState();
177
- const initialPageCount = Math.max(1, Math.ceil(initialEntries.length / PROFILES_PER_PAGE));
178
- const specializations = Array.from(new Set(profiles.flatMap((profile) => profile.contributions.flatMap((contribution) => contribution.areas)))).sort((left, right) => formatLabel(left).localeCompare(formatLabel(right)));
44
+ export function generateLeaderboardHTML(profiles, options = {}) {
45
+ const teams = collectTeams(profiles);
46
+ const specializations = Array.from(new Set(profiles.flatMap((profile) => (profile.contributions ?? []).flatMap((contribution) => contribution.areas)))).sort((left, right) => formatLabel(left).localeCompare(formatLabel(right)));
47
+ const initialMode = options.initialMode === 'team' ? 'team' : 'profile';
48
+ const initialTeam = teams.find((team) => normalizeTeamKey(team) === normalizeTeamKey(options.initialTeam)) ?? 'all';
49
+ const initialPageCount = Math.max(1, Math.ceil(Math.max(profiles.length, 1) / 40));
50
+ const initialBoard = profiles.length === 0
51
+ ? '<div class="empty">No generated profiles found in ~/.quackscore yet.</div>'
52
+ : '<div class="empty">Loading leaderboard…</div>';
179
53
  return `<!DOCTYPE html>
180
54
  <html lang="en">
181
55
  <head>
@@ -183,678 +57,568 @@ export function generateLeaderboardHTML(profiles) {
183
57
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
184
58
  <title>Quackscore Leaderboard</title>
185
59
  <style>
186
- @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
60
+ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;800&family=IBM+Plex+Mono:wght@400;500;700&family=Manrope:wght@400;500;700;800&display=swap');
187
61
 
188
62
  :root {
189
63
  color-scheme: dark;
190
- --bg: #080808;
191
- --panel: rgba(16, 16, 16, 0.95);
192
- --panel-soft: rgba(24, 24, 24, 0.88);
193
- --border: rgba(255,255,255,0.08);
194
- --text: #f5f5f5;
195
- --muted: #9a9a9a;
196
- --amber: #f5b331;
197
- --blue: #7da2ff;
198
- --violet: #b380ff;
199
- --green: #7dd3a7;
64
+ --bg: #090b10;
65
+ --panel: rgba(16, 20, 28, 0.88);
66
+ --panel-strong: rgba(12, 16, 23, 0.96);
67
+ --text: #eef2ff;
68
+ --muted: #97a0b4;
69
+ --line: rgba(255, 255, 255, 0.1);
70
+ --accent: #f5b331;
71
+ --accent-soft: rgba(245, 179, 49, 0.14);
72
+ --forest: #8ca5ff;
73
+ --gold: #ffd36c;
74
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
200
75
  }
76
+
201
77
  * { box-sizing: border-box; }
78
+
202
79
  body {
203
80
  margin: 0;
204
81
  min-height: 100vh;
205
- font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
82
+ font-family: "Manrope", sans-serif;
206
83
  color: var(--text);
207
84
  background:
208
- radial-gradient(circle at top left, rgba(245,179,49,0.12), transparent 30%),
209
- radial-gradient(circle at top right, rgba(125,162,255,0.09), transparent 26%),
210
- linear-gradient(180deg, #050505 0%, #0b0b0b 100%);
85
+ radial-gradient(circle at top left, rgba(245, 179, 49, 0.14), transparent 22%),
86
+ radial-gradient(circle at 84% 14%, rgba(140, 165, 255, 0.14), transparent 20%),
87
+ linear-gradient(180deg, #0c1018 0%, #06080d 100%);
211
88
  }
89
+
212
90
  .shell {
213
- width: min(1100px, calc(100vw - 32px));
91
+ width: min(1180px, calc(100vw - 28px));
214
92
  margin: 0 auto;
215
- padding: 32px 0 48px;
93
+ padding: 28px 0 44px;
216
94
  }
95
+
96
+ .hero,
97
+ .filters,
98
+ .board,
99
+ .pagination {
100
+ position: relative;
101
+ }
102
+
217
103
  .hero {
218
104
  display: grid;
219
105
  grid-template-columns: minmax(0, 1fr) auto;
220
- gap: 20px;
221
- margin-bottom: 20px;
106
+ gap: 18px;
107
+ margin-bottom: 18px;
222
108
  padding: 26px 28px;
223
109
  border-radius: 28px;
224
- border: 1px solid rgba(245,179,49,0.16);
225
110
  background:
226
- radial-gradient(circle at 12% 18%, rgba(245,179,49,0.15), transparent 26%),
227
- radial-gradient(circle at 82% 12%, rgba(125,162,255,0.12), transparent 24%),
228
- linear-gradient(180deg, rgba(14,14,14,0.96), rgba(10,10,10,0.98));
229
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
230
- }
231
- .hero-copy {
232
- display: grid;
233
- gap: 10px;
111
+ linear-gradient(135deg, rgba(20,24,33,0.94), rgba(10,12,19,0.97)),
112
+ radial-gradient(circle at right top, rgba(245,179,49,0.12), transparent 32%);
113
+ border: 1px solid rgba(255, 255, 255, 0.08);
114
+ box-shadow: var(--shadow);
234
115
  }
116
+
235
117
  .hero-topline {
236
- display: flex;
118
+ display: inline-flex;
237
119
  align-items: center;
238
- gap: 12px;
239
- font-family: "JetBrains Mono", ui-monospace, monospace;
240
- letter-spacing: 0.18em;
241
- text-transform: uppercase;
120
+ gap: 10px;
121
+ font-family: "IBM Plex Mono", monospace;
242
122
  font-size: 0.78rem;
123
+ letter-spacing: 0.14em;
124
+ text-transform: uppercase;
243
125
  color: var(--muted);
244
126
  }
245
- .duck-badge {
246
- width: 48px;
247
- height: 48px;
248
- display: grid;
249
- place-items: center;
250
- border-radius: 16px;
251
- background: linear-gradient(180deg, rgba(245,179,49,0.22), rgba(245,179,49,0.08));
252
- border: 1px solid rgba(245,179,49,0.28);
253
- font-size: 1.6rem;
254
- box-shadow: 0 12px 28px rgba(0,0,0,0.28);
255
- }
127
+
256
128
  .hero h1 {
257
- margin: 0 0 8px;
258
- font-family: "Cinzel", ui-serif, Georgia, serif;
259
- font-size: clamp(2rem, 4vw, 3.7rem);
260
- line-height: 1;
261
- color: #ffd36f;
262
- text-shadow: 0 0 18px rgba(245,179,49,0.18);
129
+ margin: 10px 0 8px;
130
+ font-family: "Cinzel", serif;
131
+ font-size: clamp(2.1rem, 4vw, 4rem);
132
+ line-height: 0.95;
133
+ color: #f3c45c;
263
134
  }
135
+
264
136
  .hero p {
265
137
  margin: 0;
266
138
  max-width: 760px;
267
- color: var(--muted);
268
139
  line-height: 1.7;
140
+ color: var(--muted);
141
+ }
142
+
143
+ .hero-stats {
144
+ min-width: 190px;
145
+ display: grid;
146
+ gap: 10px;
147
+ align-content: end;
269
148
  }
270
- .count {
271
- align-self: end;
272
- min-width: 168px;
273
- padding: 16px 18px;
149
+
150
+ .hero-stat {
151
+ padding: 14px 16px;
274
152
  border-radius: 18px;
275
- border: 1px solid var(--border);
276
153
  background: rgba(255,255,255,0.04);
277
- font-size: 0.95rem;
154
+ border: 1px solid rgba(255, 255, 255, 0.08);
155
+ }
156
+
157
+ .hero-stat span {
158
+ display: block;
278
159
  color: var(--muted);
279
- font-family: "JetBrains Mono", ui-monospace, monospace;
160
+ font-size: 0.78rem;
161
+ text-transform: uppercase;
162
+ letter-spacing: 0.1em;
163
+ font-family: "IBM Plex Mono", monospace;
280
164
  }
281
- .count strong {
165
+
166
+ .hero-stat strong {
282
167
  display: block;
283
- color: var(--amber);
168
+ margin-top: 8px;
284
169
  font-size: 1.6rem;
285
- font-family: "Cinzel", ui-serif, Georgia, serif;
170
+ color: #dbe4ff;
286
171
  }
172
+
287
173
  .filters {
288
174
  display: grid;
289
- grid-template-columns: repeat(2, minmax(0, 1fr));
175
+ grid-template-columns: repeat(4, minmax(0, 1fr));
290
176
  gap: 14px;
291
- margin-bottom: 20px;
292
- padding: 18px 20px;
293
- border-radius: 22px;
294
- border: 1px solid var(--border);
295
- background: linear-gradient(180deg, rgba(18,18,18,0.96), rgba(12,12,12,0.92));
177
+ margin-bottom: 18px;
178
+ padding: 20px 22px;
179
+ border-radius: 24px;
180
+ background: var(--panel);
181
+ border: 1px solid rgba(255, 255, 255, 0.08);
182
+ box-shadow: var(--shadow);
296
183
  }
184
+
297
185
  .filter-group {
298
186
  display: grid;
299
187
  gap: 8px;
300
188
  }
189
+
190
+ .filter-group.wide {
191
+ grid-column: span 2;
192
+ }
193
+
301
194
  .filter-label {
195
+ color: var(--muted);
302
196
  font-size: 0.76rem;
303
197
  letter-spacing: 0.12em;
304
198
  text-transform: uppercase;
305
- color: var(--muted);
306
- font-family: "JetBrains Mono", ui-monospace, monospace;
199
+ font-family: "IBM Plex Mono", monospace;
307
200
  }
308
- .filter-dropdown {
309
- position: relative;
201
+
202
+ .mode-toggle {
203
+ display: inline-flex;
204
+ gap: 8px;
310
205
  }
311
- .filter-trigger {
312
- width: 100%;
313
- padding: 14px 68px 14px 16px;
314
- border-radius: 16px;
315
- border: 1px solid rgba(255,255,255,0.1);
316
- background:
317
- linear-gradient(180deg, rgba(34,34,34,0.96), rgba(25,25,25,0.96));
206
+
207
+ .mode-button,
208
+ .select,
209
+ .date-input,
210
+ .page-button,
211
+ .member-link,
212
+ .empty-pill {
213
+ border-radius: 14px;
214
+ border: 1px solid var(--line);
215
+ background: rgba(255,255,255,0.04);
318
216
  color: var(--text);
319
217
  font: inherit;
320
- text-align: left;
321
- cursor: pointer;
322
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
323
- transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
324
218
  }
325
- .filter-trigger:hover {
326
- border-color: rgba(125,162,255,0.22);
327
- background: linear-gradient(180deg, rgba(38,38,38,0.98), rgba(28,28,28,0.98));
328
- }
329
- .filter-trigger:focus-visible,
330
- .filter-dropdown.is-open .filter-trigger {
331
- outline: none;
332
- border-color: rgba(125,162,255,0.42);
333
- background:
334
- radial-gradient(circle at right top, rgba(125,162,255,0.14), transparent 42%),
335
- linear-gradient(180deg, rgba(36,36,36,0.98), rgba(24,24,24,0.98));
336
- box-shadow:
337
- 0 0 0 1px rgba(125,162,255,0.18),
338
- 0 18px 38px rgba(0,0,0,0.26);
339
- }
340
- .filter-trigger::before {
341
- content: '';
342
- position: absolute;
343
- top: 50%;
344
- right: 50px;
345
- width: 1px;
346
- height: 22px;
347
- border-radius: 999px;
348
- background: rgba(255,255,255,0.1);
349
- transform: translateY(-50%);
350
- pointer-events: none;
351
- }
352
- .filter-trigger::after {
353
- content: '';
354
- position: absolute;
355
- top: 50%;
356
- right: 22px;
357
- width: 8px;
358
- height: 8px;
359
- border-right: 2px solid rgba(255,255,255,0.78);
360
- border-bottom: 2px solid rgba(255,255,255,0.78);
361
- transform: translateY(-65%) rotate(45deg);
362
- pointer-events: none;
363
- }
364
- .filter-dropdown.is-open .filter-trigger::after {
365
- transform: translateY(-20%) rotate(-135deg);
366
- }
367
- .filter-trigger-label {
368
- display: block;
369
- overflow: hidden;
370
- text-overflow: ellipsis;
371
- white-space: nowrap;
372
- }
373
- .filter-menu {
374
- position: absolute;
375
- top: calc(100% + 10px);
376
- left: 0;
377
- right: 0;
378
- z-index: 20;
379
- display: grid;
380
- gap: 4px;
381
- padding: 8px;
382
- border-radius: 18px;
383
- border: 1px solid rgba(125,162,255,0.18);
384
- background:
385
- radial-gradient(circle at top right, rgba(125,162,255,0.14), transparent 34%),
386
- linear-gradient(180deg, rgba(20,20,20,0.98), rgba(12,12,12,0.98));
387
- box-shadow:
388
- 0 22px 44px rgba(0,0,0,0.46),
389
- inset 0 1px 0 rgba(255,255,255,0.04);
390
- }
391
- .filter-menu[hidden] {
392
- display: none;
393
- }
394
- .filter-option {
395
- width: 100%;
396
- padding: 11px 14px;
397
- border: 0;
398
- border-radius: 12px;
399
- background: transparent;
400
- color: #ececec;
401
- font: inherit;
402
- text-align: left;
219
+
220
+ .mode-button {
221
+ padding: 12px 14px;
403
222
  cursor: pointer;
404
- transition: background 140ms ease, color 140ms ease;
405
- }
406
- .filter-option:hover,
407
- .filter-option:focus-visible {
408
- outline: none;
409
- background: rgba(255,255,255,0.07);
410
- color: #ffffff;
411
- }
412
- .filter-option.is-selected {
413
- background: linear-gradient(180deg, rgba(125,162,255,0.28), rgba(91,122,219,0.24));
414
- color: #ffffff;
415
- box-shadow: inset 0 0 0 1px rgba(125,162,255,0.18);
223
+ font-weight: 700;
416
224
  }
417
- .filter-summary {
418
- grid-column: 1 / -1;
419
- color: var(--muted);
420
- font-size: 0.92rem;
421
- }
422
- .filter-summary strong {
423
- color: var(--amber);
225
+
226
+ .mode-button.is-active {
227
+ background: linear-gradient(135deg, var(--accent-soft), rgba(255,255,255,0.02));
228
+ border-color: rgba(245, 179, 49, 0.34);
229
+ color: #ffd36c;
424
230
  }
425
- .filter-date-input {
231
+
232
+ .select,
233
+ .date-input {
426
234
  width: 100%;
427
- padding: 14px 16px;
428
- border-radius: 16px;
429
- border: 1px solid rgba(255,255,255,0.1);
430
- background: linear-gradient(180deg, rgba(34,34,34,0.96), rgba(25,25,25,0.96));
431
- color: var(--text);
432
- font: inherit;
433
- cursor: pointer;
434
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
435
- transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
436
- }
437
- .filter-date-input:hover {
438
- border-color: rgba(125,162,255,0.22);
439
- background: linear-gradient(180deg, rgba(38,38,38,0.98), rgba(28,28,28,0.98));
440
- }
441
- .filter-date-input:focus {
442
- outline: none;
443
- border-color: rgba(125,162,255,0.42);
444
- box-shadow: 0 0 0 1px rgba(125,162,255,0.18), 0 18px 38px rgba(0,0,0,0.26);
445
- }
446
- .filter-date-input::-webkit-calendar-picker-indicator {
447
- filter: invert(0.8) opacity(0.7);
448
- cursor: pointer;
449
- }
450
- .modal-overlay {
451
- position: fixed;
452
- inset: 0;
453
- z-index: 100;
454
- display: grid;
455
- place-items: center;
456
- background: rgba(0,0,0,0.6);
457
- backdrop-filter: blur(4px);
458
- }
459
- .modal-overlay[hidden] {
460
- display: none;
461
- }
462
- .modal {
463
- width: min(440px, calc(100vw - 40px));
464
- padding: 28px;
465
- border-radius: 24px;
466
- border: 1px solid rgba(125,162,255,0.22);
467
- background:
468
- radial-gradient(circle at top right, rgba(125,162,255,0.1), transparent 34%),
469
- linear-gradient(180deg, rgba(22,22,22,0.99), rgba(14,14,14,0.99));
470
- box-shadow: 0 32px 64px rgba(0,0,0,0.56);
471
- }
472
- .modal h3 {
473
- margin: 0 0 20px;
474
- font-family: "Cinzel", ui-serif, Georgia, serif;
475
- font-size: 1.1rem;
476
- color: var(--amber);
235
+ padding: 12px 14px;
477
236
  }
478
- .modal-fields {
237
+
238
+ .date-range {
479
239
  display: grid;
480
240
  grid-template-columns: 1fr 1fr;
481
- gap: 14px;
482
- margin-bottom: 22px;
483
- }
484
- .modal-field {
485
- display: grid;
486
- gap: 8px;
487
- }
488
- .modal-actions {
489
- display: flex;
490
- justify-content: flex-end;
491
241
  gap: 10px;
492
242
  }
493
- .modal-btn {
494
- padding: 10px 20px;
495
- border-radius: 12px;
496
- border: 1px solid rgba(255,255,255,0.1);
497
- background: rgba(255,255,255,0.05);
498
- color: var(--text);
499
- font: inherit;
500
- font-size: 0.9rem;
501
- cursor: pointer;
502
- transition: background 140ms ease, border-color 140ms ease;
503
- }
504
- .modal-btn:hover {
505
- background: rgba(255,255,255,0.09);
506
- }
507
- .modal-btn.primary {
508
- border-color: rgba(125,162,255,0.36);
509
- background: linear-gradient(180deg, rgba(125,162,255,0.22), rgba(91,122,219,0.18));
510
- color: #dbe5ff;
511
- font-weight: 600;
243
+
244
+ .filter-summary {
245
+ grid-column: 1 / -1;
246
+ color: var(--muted);
512
247
  }
513
- .modal-btn.primary:hover {
514
- background: linear-gradient(180deg, rgba(125,162,255,0.3), rgba(91,122,219,0.24));
248
+
249
+ .filter-summary strong {
250
+ color: var(--forest);
515
251
  }
252
+
516
253
  .board {
517
254
  display: grid;
518
- gap: 12px;
255
+ gap: 14px;
519
256
  }
257
+
520
258
  .entry {
521
- border-radius: 22px;
522
- border: 1px solid var(--border);
523
- background: linear-gradient(180deg, var(--panel), var(--panel-soft));
259
+ border-radius: 24px;
260
+ background: var(--panel-strong);
261
+ border: 1px solid rgba(255, 255, 255, 0.08);
262
+ box-shadow: var(--shadow);
524
263
  overflow: hidden;
525
264
  }
526
- .entry-summary {
265
+
266
+ .entry summary {
527
267
  list-style: none;
528
268
  cursor: pointer;
269
+ }
270
+
271
+ .entry summary::-webkit-details-marker,
272
+ .entry summary::marker {
273
+ display: none;
274
+ }
275
+
276
+ .entry-head {
529
277
  display: grid;
530
- grid-template-columns: 64px minmax(0, 1fr) auto;
278
+ grid-template-columns: 66px minmax(0, 1fr) auto;
531
279
  gap: 16px;
532
280
  align-items: center;
533
- padding: 16px 18px;
534
- }
535
- .entry-summary::-webkit-details-marker,
536
- .entry-summary::marker {
537
- display: none;
281
+ padding: 18px 20px;
538
282
  }
283
+
539
284
  .rank {
285
+ width: 52px;
286
+ height: 52px;
540
287
  display: grid;
541
288
  place-items: center;
542
- width: 48px;
543
- height: 48px;
544
- border-radius: 16px;
545
- background: linear-gradient(180deg, rgba(245,179,49,0.16), rgba(179,128,255,0.10));
546
- color: var(--amber);
289
+ border-radius: 18px;
547
290
  font-weight: 800;
548
- font-size: 1.2rem;
549
- font-family: "Cinzel", ui-serif, Georgia, serif;
291
+ font-size: 1.1rem;
292
+ font-family: "Cinzel", serif;
293
+ color: #ffd36c;
294
+ background: linear-gradient(135deg, rgba(245,179,49,0.14), rgba(255,255,255,0.04));
550
295
  }
296
+
551
297
  .entry-top {
552
298
  display: flex;
553
- align-items: start;
554
299
  justify-content: space-between;
555
300
  gap: 16px;
301
+ align-items: flex-start;
556
302
  }
303
+
557
304
  .entry-top h2 {
558
305
  margin: 0;
559
306
  font-size: 1.18rem;
560
307
  }
308
+
561
309
  .subtitle {
562
310
  margin-top: 4px;
563
- color: var(--blue);
564
- font-size: 0.88rem;
311
+ color: var(--muted);
312
+ font-size: 0.92rem;
565
313
  }
566
- .xp-block {
314
+
315
+ .score {
567
316
  text-align: right;
568
317
  white-space: nowrap;
569
- font-family: "JetBrains Mono", ui-monospace, monospace;
318
+ font-family: "IBM Plex Mono", monospace;
570
319
  }
571
- .xp-block strong {
320
+
321
+ .score strong {
572
322
  display: block;
573
- color: var(--amber);
323
+ color: var(--gold);
574
324
  font-size: 1.05rem;
575
325
  }
576
- .xp-block span,
577
- .summary,
578
- .meta-pill,
579
- .action-link,
580
- .disabled {
581
- color: var(--muted);
582
- }
583
- .meta-row {
326
+
327
+ .meta-row,
328
+ .detail-row,
329
+ .member-list {
584
330
  display: flex;
585
331
  flex-wrap: wrap;
586
332
  gap: 8px;
587
- margin-top: 10px;
588
333
  }
589
- .meta-pill {
334
+
335
+ .meta-pill,
336
+ .empty-pill {
590
337
  padding: 5px 9px;
591
- border-radius: 999px;
592
- background: rgba(255,255,255,0.05);
593
- font-size: 0.78rem;
594
- font-family: "JetBrains Mono", ui-monospace, monospace;
338
+ font-family: "IBM Plex Mono", monospace;
339
+ font-size: 0.76rem;
340
+ color: var(--muted);
341
+ background: rgba(255, 255, 255, 0.05);
595
342
  }
343
+
596
344
  .summary {
597
- margin: 10px 0 0;
598
- line-height: 1.6;
599
- font-size: 0.94rem;
600
- }
601
- .summary.preview {
602
- display: -webkit-box;
603
- -webkit-line-clamp: 2;
604
- -webkit-box-orient: vertical;
605
- overflow: hidden;
345
+ margin: 12px 0 0;
346
+ color: var(--muted);
347
+ line-height: 1.7;
606
348
  }
349
+
607
350
  .entry-side {
608
351
  display: flex;
609
352
  align-items: center;
610
353
  gap: 10px;
611
354
  }
612
- .action-link {
355
+
356
+ .member-link {
613
357
  display: inline-flex;
614
358
  align-items: center;
615
359
  justify-content: center;
616
- min-width: 112px;
617
- padding: 10px 13px;
618
- border-radius: 12px;
619
- border: 1px solid rgba(125,162,255,0.22);
620
- background: rgba(125,162,255,0.09);
621
- color: #dbe5ff;
360
+ padding: 10px 12px;
622
361
  text-decoration: none;
623
- font-weight: 600;
624
- font-size: 0.88rem;
362
+ font-weight: 700;
363
+ color: #dbe4ff;
625
364
  }
626
- .disabled {
627
- border-color: var(--border);
628
- background: rgba(255,255,255,0.04);
365
+
366
+ .member-link.disabled {
367
+ color: var(--muted);
368
+ background: rgba(255, 255, 255, 0.04);
629
369
  }
370
+
630
371
  .expand-hint {
631
- min-width: 56px;
632
- text-align: center;
633
- padding: 8px 10px;
634
- border-radius: 10px;
635
- background: rgba(255,255,255,0.04);
636
372
  color: var(--muted);
637
- font-family: "JetBrains Mono", ui-monospace, monospace;
638
- font-size: 0.76rem;
373
+ font-family: "IBM Plex Mono", monospace;
374
+ font-size: 0.75rem;
639
375
  text-transform: uppercase;
640
- letter-spacing: 0.08em;
641
- }
642
- .entry[open] .expand-hint {
643
- color: var(--green);
644
376
  }
377
+
645
378
  .entry-details {
646
- padding: 0 18px 18px 98px;
647
- border-top: 1px solid rgba(255,255,255,0.06);
648
- background: rgba(0,0,0,0.10);
379
+ padding: 0 20px 20px 102px;
380
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
381
+ background: rgba(255, 255, 255, 0.02);
649
382
  }
650
- .detail-grid {
651
- display: grid;
652
- grid-template-columns: repeat(3, minmax(0, 1fr));
653
- gap: 10px;
383
+
384
+ .detail-row {
654
385
  margin-top: 14px;
655
386
  }
656
- .detail-box {
387
+
388
+ .detail-card {
389
+ min-width: 180px;
657
390
  padding: 12px 14px;
658
- border-radius: 14px;
659
- border: 1px solid rgba(255,255,255,0.06);
391
+ border-radius: 16px;
392
+ border: 1px solid rgba(255, 255, 255, 0.08);
660
393
  background: rgba(255,255,255,0.03);
661
394
  }
662
- .detail-label {
395
+
396
+ .detail-card span {
663
397
  display: block;
664
- margin-bottom: 6px;
665
398
  color: var(--muted);
666
399
  font-size: 0.74rem;
667
- font-family: "JetBrains Mono", ui-monospace, monospace;
668
400
  text-transform: uppercase;
669
401
  letter-spacing: 0.12em;
402
+ font-family: "IBM Plex Mono", monospace;
670
403
  }
671
- .detail-box strong {
672
- color: var(--text);
673
- font-size: 0.95rem;
404
+
405
+ .detail-card strong {
406
+ display: block;
407
+ margin-top: 8px;
408
+ }
409
+
410
+ .member-list {
411
+ margin-top: 14px;
674
412
  }
413
+
414
+ .member-tag {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ gap: 8px;
418
+ padding: 8px 10px;
419
+ border-radius: 999px;
420
+ border: 1px solid rgba(255, 255, 255, 0.08);
421
+ background: rgba(255,255,255,0.04);
422
+ }
423
+
424
+ .member-tag a {
425
+ color: #dbe4ff;
426
+ text-decoration: none;
427
+ font-weight: 700;
428
+ }
429
+
430
+ .member-tag span {
431
+ color: var(--muted);
432
+ font-size: 0.82rem;
433
+ }
434
+
435
+ .empty {
436
+ padding: 28px;
437
+ border-radius: 24px;
438
+ background: var(--panel);
439
+ border: 1px dashed rgba(255, 255, 255, 0.14);
440
+ color: var(--muted);
441
+ text-align: center;
442
+ }
443
+
675
444
  .pagination {
676
445
  display: flex;
677
446
  align-items: center;
678
- justify-content: space-between;
679
- gap: 18px;
447
+ justify-content: center;
448
+ gap: 14px;
680
449
  margin-top: 18px;
681
- padding-top: 18px;
682
- }
683
- .pagination[hidden] {
684
- display: none;
685
450
  }
686
- .page-btn {
687
- min-width: 110px;
451
+
452
+ .page-button {
688
453
  padding: 10px 14px;
689
- border-radius: 12px;
690
- border: 1px solid rgba(255,255,255,0.08);
691
- background: rgba(255,255,255,0.04);
692
- color: var(--text);
693
- font-family: "JetBrains Mono", ui-monospace, monospace;
694
454
  cursor: pointer;
695
455
  }
696
- .page-btn:disabled {
697
- opacity: 0.45;
456
+
457
+ .page-button:disabled {
458
+ opacity: 0.4;
698
459
  cursor: not-allowed;
699
460
  }
461
+
700
462
  .page-status {
701
463
  color: var(--muted);
702
- font-family: "JetBrains Mono", ui-monospace, monospace;
703
- text-align: center;
704
- flex: 1 1 auto;
464
+ font-family: "IBM Plex Mono", monospace;
705
465
  }
706
- .page-status strong {
707
- color: var(--amber);
466
+
467
+ .hidden {
468
+ display: none !important;
708
469
  }
709
- .empty {
710
- padding: 32px;
711
- border-radius: 22px;
712
- border: 1px dashed var(--border);
713
- text-align: center;
714
- color: var(--muted);
470
+
471
+ @media (max-width: 980px) {
472
+ .filters {
473
+ grid-template-columns: repeat(2, minmax(0, 1fr));
474
+ }
475
+
476
+ .filter-group.wide {
477
+ grid-column: span 2;
478
+ }
715
479
  }
716
- @media (max-width: 900px) {
480
+
481
+ @media (max-width: 720px) {
482
+ .shell {
483
+ width: min(100vw - 18px, 1180px);
484
+ padding-top: 18px;
485
+ }
486
+
717
487
  .hero,
718
488
  .filters,
719
- .entry-summary,
720
- .detail-grid {
489
+ .entry-head {
721
490
  grid-template-columns: 1fr;
722
491
  }
723
- .count {
724
- min-width: 0;
725
- }
726
- .entry-side {
727
- justify-content: start;
728
- }
492
+
729
493
  .entry-details {
730
- padding-left: 18px;
494
+ padding-left: 20px;
731
495
  }
732
- }
733
- @media (max-width: 720px) {
734
- .entry-top {
735
- flex-direction: column;
496
+
497
+ .entry-side,
498
+ .score {
499
+ justify-content: flex-start;
500
+ text-align: left;
736
501
  }
737
- .entry-side {
738
- flex-wrap: wrap;
502
+
503
+ .date-range {
504
+ grid-template-columns: 1fr;
739
505
  }
740
506
  }
741
507
  </style>
742
508
  </head>
743
509
  <body>
744
510
  <div class="shell">
745
- <header class="hero">
746
- <div class="hero-copy">
747
- <div class="hero-topline"><span class="duck-badge" aria-hidden="true">&#129414;</span><span>Guild Hall Rankings</span></div>
511
+ <section class="hero">
512
+ <div>
513
+ <div class="hero-topline">Guild Hall Rankings</div>
748
514
  <h1>Quackscore Leaderboard</h1>
749
- <p>Generated from saved local profiles. Filter the hall by recent activity windows or specialization to compare the contributors already analyzed on this machine.</p>
515
+ <p>Compare saved Quackscore profiles across scopes, narrow the board to a single team, or switch to team rankings to see which group is shipping the strongest work.</p>
516
+ </div>
517
+ <div class="hero-stats">
518
+ <div class="hero-stat">
519
+ <span>Saved Profiles</span>
520
+ <strong data-profile-count>${profiles.length}</strong>
521
+ </div>
522
+ <div class="hero-stat">
523
+ <span>Tagged Teams</span>
524
+ <strong>${teams.length}</strong>
525
+ </div>
526
+ </div>
527
+ </section>
528
+
529
+ <section class="filters">
530
+ <div class="filter-group wide">
531
+ <div class="filter-label">Mode</div>
532
+ <div class="mode-toggle">
533
+ <button class="mode-button${initialMode === 'profile' ? ' is-active' : ''}" type="button" data-mode="profile">Profiles</button>
534
+ <button class="mode-button${initialMode === 'team' ? ' is-active' : ''}" type="button" data-mode="team">Teams</button>
535
+ </div>
750
536
  </div>
751
- <div class="count"><strong data-profile-count>${initialEntries.length}</strong> profiles</div>
752
- </header>
753
- <section class="filters" aria-label="Leaderboard filters">
537
+
754
538
  <div class="filter-group">
755
- <label class="filter-label" for="time-filter">Time</label>
756
- ${renderFilterDropdown('time-filter', 'data-time-filter', 'All time', [
757
- { value: 'all', label: 'All time' },
758
- { value: 'last_week', label: 'Last week' },
759
- { value: 'last_month', label: 'Last month' },
760
- { value: 'last_3_months', label: 'Last 3 months' },
761
- { value: 'last_6_months', label: 'Last 6 months' },
762
- { value: 'last_year', label: 'Last year' },
763
- { value: 'custom', label: 'Custom range...' },
764
- ])}
539
+ <label class="filter-label" for="time-filter">Time Window</label>
540
+ <select class="select" id="time-filter" data-time-filter>
541
+ <option value="all">All time</option>
542
+ <option value="last_week">Last week</option>
543
+ <option value="last_month">Last month</option>
544
+ <option value="last_3_months">Last 3 months</option>
545
+ <option value="last_6_months">Last 6 months</option>
546
+ <option value="last_year">Last year</option>
547
+ <option value="custom">Custom range</option>
548
+ </select>
765
549
  </div>
550
+
766
551
  <div class="filter-group">
767
552
  <label class="filter-label" for="specialization-filter">Specialization</label>
768
- ${renderFilterDropdown('specialization-filter', 'data-specialization-filter', 'All specializations', [
769
- { value: 'all', label: 'All specializations' },
770
- ...specializations.map((specialization) => ({ value: specialization, label: formatLabel(specialization) })),
771
- ])}
553
+ <select class="select" id="specialization-filter" data-specialization-filter>
554
+ <option value="all">All specializations</option>
555
+ ${specializations.map((specialization) => `<option value="${escapeHtml(specialization)}">${escapeHtml(formatLabel(specialization))}</option>`).join('')}
556
+ </select>
772
557
  </div>
773
- <div class="filter-summary" data-filter-summary><strong>${initialEntries.length}</strong> profiles for all time across all specializations.</div>
774
- </section>
775
- <section class="board" data-board>${initialRows}</section>
776
- <div class="pagination" aria-label="Leaderboard pagination" data-pagination${initialPageCount <= 1 ? ' hidden' : ''}>
777
- <button class="page-btn" type="button" data-nav="prev">Previous</button>
778
- <div class="page-status" data-page-status>Page <strong>1</strong> of ${initialPageCount}</div>
779
- <button class="page-btn" type="button" data-nav="next">Next</button>
780
- </div>
781
- </div>
782
- <div class="modal-overlay" id="date-range-modal" hidden>
783
- <div class="modal">
784
- <h3>Custom Date Range</h3>
785
- <div class="modal-fields">
786
- <div class="modal-field">
787
- <label class="filter-label" for="modal-from">From</label>
788
- <input class="filter-date-input" type="date" id="modal-from" data-modal-from>
789
- </div>
790
- <div class="modal-field">
791
- <label class="filter-label" for="modal-to">To</label>
792
- <input class="filter-date-input" type="date" id="modal-to" data-modal-to>
793
- </div>
558
+
559
+ <div class="filter-group">
560
+ <label class="filter-label" for="team-filter">Team Filter</label>
561
+ <select class="select" id="team-filter" data-team-filter>
562
+ <option value="all"${initialTeam === 'all' ? ' selected' : ''}>All teams</option>
563
+ ${teams.map((team) => `<option value="${escapeHtml(team)}"${initialTeam === team ? ' selected' : ''}>${escapeHtml(team)}</option>`).join('')}
564
+ </select>
794
565
  </div>
795
- <div class="modal-actions">
796
- <button class="modal-btn" type="button" data-modal-cancel>Cancel</button>
797
- <button class="modal-btn primary" type="button" data-modal-apply>Apply</button>
566
+
567
+ <div class="filter-group wide hidden" data-custom-range>
568
+ <div class="filter-label">Custom Range</div>
569
+ <div class="date-range">
570
+ <input class="date-input" type="date" data-from-date>
571
+ <input class="date-input" type="date" data-to-date>
572
+ </div>
798
573
  </div>
574
+
575
+ <div class="filter-summary" data-filter-summary><strong>${profiles.length}</strong> saved profiles ready for comparison.</div>
576
+ </section>
577
+
578
+ <section class="board" data-board>
579
+ ${initialBoard}
580
+ </section>
581
+
582
+ <div class="pagination" data-pagination hidden>
583
+ <button class="page-button" type="button" data-nav="prev">Previous</button>
584
+ <div class="page-status" data-page-status>Page <strong>1</strong> of ${initialPageCount}</div>
585
+ <button class="page-button" type="button" data-nav="next">Next</button>
799
586
  </div>
800
587
  </div>
588
+
801
589
  <script id="leaderboard-data" type="application/json">${serializeForScript(profiles)}</script>
590
+ <script id="leaderboard-config" type="application/json">${serializeForScript({ initialMode, initialTeam })}</script>
802
591
  <script>
803
- (function() {
804
- const PROFILES_PER_PAGE = ${PROFILES_PER_PAGE};
592
+ (function () {
593
+ const PROFILES_PER_PAGE = 40;
805
594
  const DISPLAY_LABELS = ${serializeForScript(DISPLAY_LABELS)};
806
595
  const LEVEL_THRESHOLDS = ${serializeForScript(LEVEL_THRESHOLDS)};
807
596
  const MAX_LEVEL = ${MAX_LEVEL};
808
- const TIME_LABELS = {
809
- all: 'all time',
810
- last_week: 'the last week',
811
- last_month: 'the last month',
812
- last_3_months: 'the last 3 months',
813
- last_6_months: 'the last 6 months',
814
- last_year: 'the last year',
815
- };
816
597
 
817
598
  const dataElement = document.getElementById('leaderboard-data');
599
+ const configElement = document.getElementById('leaderboard-config');
818
600
  const board = document.querySelector('[data-board]');
819
- const timeFilter = document.querySelector('[data-time-filter]');
820
- const specializationFilter = document.querySelector('[data-specialization-filter]');
821
- const count = document.querySelector('[data-profile-count]');
822
- const filterSummary = document.querySelector('[data-filter-summary]');
601
+ const summary = document.querySelector('[data-filter-summary]');
823
602
  const pagination = document.querySelector('[data-pagination]');
603
+ const pageStatus = document.querySelector('[data-page-status]');
824
604
  const prevButton = document.querySelector('[data-nav="prev"]');
825
605
  const nextButton = document.querySelector('[data-nav="next"]');
826
- const pageStatus = document.querySelector('[data-page-status]');
827
-
828
- const modalOverlay = document.getElementById('date-range-modal');
829
- const modalFromInput = document.querySelector('[data-modal-from]');
830
- const modalToInput = document.querySelector('[data-modal-to]');
831
- const modalCancelBtn = document.querySelector('[data-modal-cancel]');
832
- const modalApplyBtn = document.querySelector('[data-modal-apply]');
606
+ const timeFilter = document.querySelector('[data-time-filter]');
607
+ const specializationFilter = document.querySelector('[data-specialization-filter]');
608
+ const teamFilter = document.querySelector('[data-team-filter]');
609
+ const customRange = document.querySelector('[data-custom-range]');
610
+ const fromDate = document.querySelector('[data-from-date]');
611
+ const toDate = document.querySelector('[data-to-date]');
612
+ const modeButtons = Array.from(document.querySelectorAll('[data-mode]'));
833
613
 
834
- if (!dataElement || !board || !timeFilter || !specializationFilter || !count || !filterSummary || !pagination || !prevButton || !nextButton || !pageStatus || !modalOverlay || !modalFromInput || !modalToInput || !modalCancelBtn || !modalApplyBtn) {
614
+ if (!dataElement || !configElement || !board || !summary || !pagination || !pageStatus || !prevButton || !nextButton || !timeFilter || !specializationFilter || !teamFilter || !customRange || !fromDate || !toDate || modeButtons.length === 0) {
835
615
  return;
836
616
  }
837
617
 
838
618
  const profiles = JSON.parse(dataElement.textContent || '[]');
619
+ const config = JSON.parse(configElement.textContent || '{}');
620
+ let currentMode = config.initialMode || 'profile';
839
621
  let currentPage = 1;
840
- let customFrom = '';
841
- let customTo = '';
842
- let previousTimeValue = 'all';
843
- const timeDropdown = setupDropdown(timeFilter);
844
- const dropdowns = [timeDropdown, setupDropdown(specializationFilter)].filter(Boolean);
845
-
846
- document.addEventListener('click', (event) => {
847
- dropdowns.forEach((instance) => {
848
- if (!instance.dropdown.contains(event.target)) {
849
- instance.close();
850
- }
851
- });
852
- });
853
-
854
- document.addEventListener('keydown', (event) => {
855
- if (event.key !== 'Escape') return;
856
- dropdowns.forEach((instance) => instance.close());
857
- });
858
622
 
859
623
  const escapeHtml = (value) => String(value)
860
624
  .replace(/&/g, '&amp;')
@@ -865,7 +629,7 @@ body {
865
629
 
866
630
  const formatLabel = (value) => {
867
631
  if (!value) return 'Unspecified';
868
- return DISPLAY_LABELS[value] || value.split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
632
+ return DISPLAY_LABELS[value] || String(value).split('_').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
869
633
  };
870
634
 
871
635
  const compactText = (value, maxLength) => {
@@ -882,95 +646,35 @@ body {
882
646
  return new Date(parsed).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' });
883
647
  };
884
648
 
885
- function setupDropdown(input) {
886
- const dropdown = input.closest('[data-filter-dropdown]');
887
- const trigger = dropdown ? dropdown.querySelector('[data-filter-trigger]') : null;
888
- const label = dropdown ? dropdown.querySelector('[data-filter-label]') : null;
889
- const menu = dropdown ? dropdown.querySelector('[data-filter-menu]') : null;
890
- const options = menu ? Array.from(menu.querySelectorAll('[data-filter-option]')) : [];
891
-
892
- if (!dropdown || !trigger || !label || !menu || options.length === 0) {
893
- return null;
894
- }
895
-
896
- const close = () => {
897
- dropdown.classList.remove('is-open');
898
- trigger.setAttribute('aria-expanded', 'false');
899
- menu.hidden = true;
900
- };
901
-
902
- const closeOthers = () => {
903
- document.querySelectorAll('[data-filter-dropdown].is-open').forEach((openDropdown) => {
904
- if (openDropdown === dropdown) return;
905
- openDropdown.classList.remove('is-open');
906
- const openTrigger = openDropdown.querySelector('[data-filter-trigger]');
907
- const openMenu = openDropdown.querySelector('[data-filter-menu]');
908
- if (openTrigger) openTrigger.setAttribute('aria-expanded', 'false');
909
- if (openMenu) openMenu.hidden = true;
910
- });
911
- };
912
-
913
- const open = () => {
914
- closeOthers();
915
- dropdown.classList.add('is-open');
916
- trigger.setAttribute('aria-expanded', 'true');
917
- menu.hidden = false;
918
- };
919
-
920
- const setValue = (value, shouldDispatch) => {
921
- const selectedOption = options.find((option) => option.getAttribute('data-value') === value) || options[0];
922
- if (!selectedOption) return;
923
-
924
- input.value = selectedOption.getAttribute('data-value') || '';
925
- label.textContent = selectedOption.textContent || '';
926
-
927
- options.forEach((option) => {
928
- const isSelected = option === selectedOption;
929
- option.classList.toggle('is-selected', isSelected);
930
- option.setAttribute('aria-selected', isSelected ? 'true' : 'false');
931
- });
932
-
933
- if (shouldDispatch) {
934
- input.dispatchEvent(new Event('change', { bubbles: true }));
935
- }
936
- };
937
-
938
- setValue(input.value, false);
939
-
940
- trigger.addEventListener('click', () => {
941
- if (dropdown.classList.contains('is-open')) close();
942
- else open();
943
- });
944
-
945
- options.forEach((option) => {
946
- option.addEventListener('click', () => {
947
- setValue(option.getAttribute('data-value') || '', true);
948
- close();
949
- trigger.focus();
950
- });
951
- });
952
-
953
- return { dropdown, close, setValue };
954
- }
955
-
956
649
  const calculateLevel = (totalPoints) => {
957
650
  let level = 1;
958
651
  for (let i = 1; i < LEVEL_THRESHOLDS.length; i += 1) {
959
- if (totalPoints >= LEVEL_THRESHOLDS[i]) {
960
- level = i + 1;
961
- } else {
962
- return level;
963
- }
652
+ if (totalPoints >= LEVEL_THRESHOLDS[i]) level = i + 1;
653
+ else return level;
964
654
  }
965
655
  return MAX_LEVEL;
966
656
  };
967
657
 
968
- const compareEntries = (left, right) =>
658
+ const compareProfileEntries = (left, right) =>
969
659
  right.totalPoints - left.totalPoints
970
660
  || right.analyzedPRs - left.analyzedPRs
971
661
  || Date.parse(right.lastActivityAt || right.lastUpdated) - Date.parse(left.lastActivityAt || left.lastUpdated)
972
662
  || left.username.localeCompare(right.username);
973
663
 
664
+ const compareTeamEntries = (left, right) =>
665
+ right.totalPoints - left.totalPoints
666
+ || right.memberCount - left.memberCount
667
+ || right.analyzedPRs - left.analyzedPRs
668
+ || Date.parse(right.lastActivityAt || '') - Date.parse(left.lastActivityAt || '')
669
+ || left.team.localeCompare(right.team);
670
+
671
+ const normalizeTeamKey = (value) => String(value || '').trim().toLowerCase();
672
+
673
+ const hasTeam = (teams, selectedTeam) => {
674
+ if (selectedTeam === 'all') return true;
675
+ return (teams || []).some((team) => normalizeTeamKey(team) === normalizeTeamKey(selectedTeam));
676
+ };
677
+
974
678
  const cutoffFor = (timeValue) => {
975
679
  if (timeValue === 'all' || timeValue === 'custom') return null;
976
680
  const now = new Date();
@@ -983,154 +687,239 @@ body {
983
687
  return cutoff.getTime();
984
688
  };
985
689
 
986
- const buildEntry = (profile, timeValue, specializationValue) => {
987
- if (!profile.contributions.length) {
988
- const specializationMatches = specializationValue === 'all' || profile.primaryArea === specializationValue;
989
- if (timeValue !== 'all' || !specializationMatches || profile.totalPoints <= 0) {
990
- return null;
991
- }
992
-
993
- return {
994
- storageKey: profile.storageKey,
995
- username: profile.username,
996
- totalPoints: profile.totalPoints,
997
- level: profile.level,
998
- lastUpdated: profile.lastUpdated,
999
- lastActivityAt: profile.lastActivityAt,
1000
- analyzedPRs: profile.analyzedPRs,
1001
- primaryArea: profile.primaryArea,
1002
- title: profile.title,
1003
- summary: profile.summary,
1004
- reportPath: profile.reportPath,
1005
- };
1006
- }
690
+ const getFilteredContributions = (profile) => {
691
+ const cutoff = cutoffFor(timeFilter.value);
692
+ const fromMs = timeFilter.value === 'custom' && fromDate.value ? Date.parse(fromDate.value) : null;
693
+ const toMs = timeFilter.value === 'custom' && toDate.value ? Date.parse(toDate.value + 'T23:59:59.999') : null;
694
+ const specializationValue = specializationFilter.value;
1007
695
 
1008
- const cutoff = cutoffFor(timeValue);
1009
- const customFromMs = timeValue === 'custom' && customFrom ? Date.parse(customFrom) : null;
1010
- const customToMs = timeValue === 'custom' && customTo ? Date.parse(customTo + 'T23:59:59.999') : null;
1011
- const filtered = profile.contributions.filter((contribution) => {
696
+ return (profile.contributions || []).filter((contribution) => {
1012
697
  const mergedAt = Date.parse(contribution.mergedAt);
1013
698
  if (Number.isNaN(mergedAt)) return false;
1014
699
  if (cutoff != null && mergedAt < cutoff) return false;
1015
- if (customFromMs != null && mergedAt < customFromMs) return false;
1016
- if (customToMs != null && mergedAt > customToMs) return false;
700
+ if (fromMs != null && mergedAt < fromMs) return false;
701
+ if (toMs != null && mergedAt > toMs) return false;
1017
702
  if (specializationValue !== 'all' && !contribution.areas.includes(specializationValue)) return false;
1018
703
  return true;
1019
704
  });
705
+ };
1020
706
 
1021
- if (!filtered.length) return null;
707
+ const buildProfileEntries = () => profiles
708
+ .filter((profile) => hasTeam(profile.teams, teamFilter.value))
709
+ .map((profile) => {
710
+ const contributions = getFilteredContributions(profile);
711
+ if (contributions.length === 0) {
712
+ if (timeFilter.value !== 'all' && timeFilter.value !== 'custom') return null;
713
+ if (timeFilter.value === 'custom' && (fromDate.value || toDate.value)) return null;
714
+ if (specializationFilter.value !== 'all') return null;
715
+ if ((profile.totalPoints || 0) <= 0) return null;
716
+ return {
717
+ kind: 'profile',
718
+ storageKey: profile.storageKey,
719
+ username: profile.username,
720
+ scopeLabel: profile.scopeLabel,
721
+ teams: profile.teams || [],
722
+ totalPoints: profile.totalPoints,
723
+ level: profile.level,
724
+ lastUpdated: profile.lastUpdated,
725
+ lastActivityAt: profile.lastActivityAt,
726
+ analyzedPRs: profile.analyzedPRs,
727
+ primaryArea: profile.primaryArea,
728
+ title: profile.title,
729
+ summary: profile.summary,
730
+ reportPath: profile.reportPath,
731
+ };
732
+ }
1022
733
 
1023
- const areaTotals = {};
1024
- let totalPoints = 0;
1025
- let lastActivityAt = filtered[0].mergedAt;
734
+ const areaTotals = {};
735
+ let totalPoints = 0;
736
+ let lastActivityAt = contributions[0].mergedAt;
737
+ contributions.forEach((contribution) => {
738
+ totalPoints += contribution.points;
739
+ if (Date.parse(contribution.mergedAt) > Date.parse(lastActivityAt)) lastActivityAt = contribution.mergedAt;
740
+ contribution.areas.forEach((area) => {
741
+ areaTotals[area] = (areaTotals[area] || 0) + contribution.points;
742
+ });
743
+ });
1026
744
 
1027
- filtered.forEach((contribution) => {
1028
- totalPoints += contribution.points;
1029
- if (Date.parse(contribution.mergedAt) > Date.parse(lastActivityAt)) {
1030
- lastActivityAt = contribution.mergedAt;
1031
- }
1032
- contribution.areas.forEach((area) => {
1033
- areaTotals[area] = (areaTotals[area] || 0) + contribution.points;
745
+ return {
746
+ kind: 'profile',
747
+ storageKey: profile.storageKey,
748
+ username: profile.username,
749
+ scopeLabel: profile.scopeLabel,
750
+ teams: profile.teams || [],
751
+ totalPoints,
752
+ level: calculateLevel(totalPoints),
753
+ lastUpdated: profile.lastUpdated,
754
+ lastActivityAt,
755
+ analyzedPRs: contributions.length,
756
+ primaryArea: Object.entries(areaTotals).sort(([, left], [, right]) => right - left)[0]?.[0],
757
+ title: profile.title,
758
+ summary: profile.summary,
759
+ reportPath: profile.reportPath,
760
+ };
761
+ })
762
+ .filter(Boolean)
763
+ .sort(compareProfileEntries);
764
+
765
+ const buildTeamEntries = () => {
766
+ const aggregate = new Map();
767
+
768
+ buildProfileEntries().forEach((profile) => {
769
+ if (!profile.teams || profile.teams.length === 0) return;
770
+ profile.teams.forEach((team) => {
771
+ const teamKey = normalizeTeamKey(team);
772
+ if (!teamKey) return;
773
+
774
+ const bucket = aggregate.get(teamKey) || {
775
+ kind: 'team',
776
+ team,
777
+ totalPoints: 0,
778
+ level: 1,
779
+ memberCount: 0,
780
+ analyzedPRs: 0,
781
+ primaryArea: undefined,
782
+ lastActivityAt: undefined,
783
+ members: [],
784
+ areaTotals: {},
785
+ };
786
+
787
+ bucket.totalPoints += profile.totalPoints;
788
+ bucket.analyzedPRs += profile.analyzedPRs;
789
+ bucket.lastActivityAt = !bucket.lastActivityAt
790
+ ? profile.lastActivityAt
791
+ : (Date.parse(profile.lastActivityAt || '') > Date.parse(bucket.lastActivityAt || '') ? profile.lastActivityAt : bucket.lastActivityAt);
792
+ bucket.members.push({
793
+ username: profile.username,
794
+ scopeLabel: profile.scopeLabel,
795
+ reportPath: profile.reportPath,
796
+ });
797
+ if (profile.primaryArea) {
798
+ bucket.areaTotals[profile.primaryArea] = (bucket.areaTotals[profile.primaryArea] || 0) + profile.totalPoints;
799
+ }
800
+ bucket.primaryArea = Object.entries(bucket.areaTotals).sort(([, left], [, right]) => right - left)[0]?.[0];
801
+ aggregate.set(teamKey, bucket);
1034
802
  });
1035
803
  });
1036
804
 
1037
- const primaryArea = Object.entries(areaTotals).sort(([, left], [, right]) => right - left)[0]?.[0];
1038
- return {
1039
- storageKey: profile.storageKey,
1040
- username: profile.username,
1041
- totalPoints,
1042
- level: calculateLevel(totalPoints),
1043
- lastUpdated: profile.lastUpdated,
1044
- lastActivityAt,
1045
- analyzedPRs: filtered.length,
1046
- primaryArea,
1047
- title: profile.title,
1048
- summary: profile.summary,
1049
- reportPath: profile.reportPath,
1050
- };
805
+ return Array.from(aggregate.values())
806
+ .map((entry) => ({
807
+ ...entry,
808
+ level: calculateLevel(entry.totalPoints),
809
+ memberCount: entry.members.length,
810
+ }))
811
+ .sort(compareTeamEntries);
1051
812
  };
1052
813
 
1053
- const renderEntry = (entry, index) => {
1054
- const preview = compactText(entry.summary, 170);
1055
- const fullSummary = compactText(entry.summary, 520);
814
+ const renderProfileEntry = (entry, index) => {
1056
815
  const reportAction = entry.reportPath
1057
- ? '<a class="action-link" href="file:///' + String(entry.reportPath).replace(/\\\\/g, '/') + '" target="_blank" rel="noopener noreferrer">Open report</a>'
1058
- : '<span class="action-link disabled">Report missing</span>';
816
+ ? '<a class="member-link" href="file:///' + String(entry.reportPath).replace(/\\\\/g, '/') + '" target="_blank" rel="noopener noreferrer">Open report</a>'
817
+ : '<span class="member-link disabled">Report missing</span>';
818
+ const teams = entry.teams.length > 0
819
+ ? entry.teams.map((team) => '<span class="meta-pill">' + escapeHtml(team) + '</span>').join('')
820
+ : '<span class="empty-pill">No teams</span>';
1059
821
 
1060
822
  return '<details class="entry">'
1061
- + '<summary class="entry-summary">'
823
+ + '<summary><div class="entry-head">'
1062
824
  + '<div class="rank">' + (index + 1) + '</div>'
1063
- + '<div class="entry-main">'
1064
- + '<div class="entry-top">'
1065
- + '<div><h2>' + escapeHtml(entry.username) + '</h2><div class="subtitle">' + escapeHtml(entry.title || 'Generated profile') + '</div></div>'
1066
- + '<div class="xp-block"><strong>' + entry.totalPoints.toLocaleString() + ' XP</strong><span>Level ' + entry.level + '</span></div>'
1067
- + '</div>'
825
+ + '<div>'
826
+ + '<div class="entry-top"><div><h2>' + escapeHtml(entry.username) + '</h2><div class="subtitle">' + escapeHtml(entry.title || 'Generated profile') + '</div></div>'
827
+ + '<div class="score"><strong>' + entry.totalPoints.toLocaleString() + ' XP</strong><span>Level ' + entry.level + '</span></div></div>'
1068
828
  + '<div class="meta-row">'
1069
829
  + '<span class="meta-pill">' + entry.analyzedPRs + ' PRs</span>'
830
+ + '<span class="meta-pill">' + escapeHtml(entry.scopeLabel) + '</span>'
1070
831
  + '<span class="meta-pill">' + escapeHtml(formatLabel(entry.primaryArea)) + '</span>'
1071
832
  + '<span class="meta-pill">Last activity ' + escapeHtml(formatDate(entry.lastActivityAt)) + '</span>'
1072
833
  + '</div>'
1073
- + '<p class="summary preview">' + escapeHtml(preview) + '</p>'
834
+ + '<p class="summary">' + escapeHtml(compactText(entry.summary, 180)) + '</p>'
1074
835
  + '</div>'
1075
836
  + '<div class="entry-side">' + reportAction + '<span class="expand-hint">More</span></div>'
1076
- + '</summary>'
837
+ + '</div></summary>'
1077
838
  + '<div class="entry-details">'
1078
- + '<p class="summary full">' + escapeHtml(fullSummary) + '</p>'
1079
- + '<div class="detail-grid">'
1080
- + '<div class="detail-box"><span class="detail-label">Profile</span><strong>' + escapeHtml(entry.title || 'Generated profile') + '</strong></div>'
1081
- + '<div class="detail-box"><span class="detail-label">Specialization</span><strong>' + escapeHtml(formatLabel(entry.primaryArea)) + '</strong></div>'
1082
- + '<div class="detail-box"><span class="detail-label">Scope</span><strong>' + escapeHtml(entry.storageKey) + '</strong></div>'
839
+ + '<p class="summary">' + escapeHtml(compactText(entry.summary, 520)) + '</p>'
840
+ + '<div class="detail-row">'
841
+ + '<div class="detail-card"><span>Scope</span><strong>' + escapeHtml(entry.scopeLabel) + '</strong></div>'
842
+ + '<div class="detail-card"><span>Teams</span><strong>' + escapeHtml(entry.teams.join(', ') || 'No teams') + '</strong></div>'
843
+ + '<div class="detail-card"><span>Specialization</span><strong>' + escapeHtml(formatLabel(entry.primaryArea)) + '</strong></div>'
1083
844
  + '</div>'
845
+ + '<div class="detail-row">' + teams + '</div>'
846
+ + '</div></details>';
847
+ };
848
+
849
+ const renderTeamEntry = (entry, index) => {
850
+ const members = entry.members.map((member) => {
851
+ if (member.reportPath) {
852
+ return '<div class="member-tag"><a href="file:///' + String(member.reportPath).replace(/\\\\/g, '/') + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(member.username) + '</a><span>' + escapeHtml(member.scopeLabel) + '</span></div>';
853
+ }
854
+ return '<div class="member-tag"><strong>' + escapeHtml(member.username) + '</strong><span>' + escapeHtml(member.scopeLabel) + '</span></div>';
855
+ }).join('');
856
+
857
+ return '<details class="entry">'
858
+ + '<summary><div class="entry-head">'
859
+ + '<div class="rank">' + (index + 1) + '</div>'
860
+ + '<div>'
861
+ + '<div class="entry-top"><div><h2>' + escapeHtml(entry.team) + '</h2><div class="subtitle">Team leaderboard entry</div></div>'
862
+ + '<div class="score"><strong>' + entry.totalPoints.toLocaleString() + ' XP</strong><span>Level ' + entry.level + '</span></div></div>'
863
+ + '<div class="meta-row">'
864
+ + '<span class="meta-pill">' + entry.memberCount + ' members</span>'
865
+ + '<span class="meta-pill">' + entry.analyzedPRs + ' PRs</span>'
866
+ + '<span class="meta-pill">' + escapeHtml(formatLabel(entry.primaryArea)) + '</span>'
867
+ + '<span class="meta-pill">Last activity ' + escapeHtml(formatDate(entry.lastActivityAt)) + '</span>'
868
+ + '</div>'
869
+ + '<p class="summary">Combined output from every saved profile tagged with this team.</p>'
1084
870
  + '</div>'
1085
- + '</details>';
871
+ + '<div class="entry-side"><span class="expand-hint">Members</span></div>'
872
+ + '</div></summary>'
873
+ + '<div class="entry-details">'
874
+ + '<div class="detail-row">'
875
+ + '<div class="detail-card"><span>Members</span><strong>' + entry.memberCount + '</strong></div>'
876
+ + '<div class="detail-card"><span>Analyzed PRs</span><strong>' + entry.analyzedPRs + '</strong></div>'
877
+ + '<div class="detail-card"><span>Primary area</span><strong>' + escapeHtml(formatLabel(entry.primaryArea)) + '</strong></div>'
878
+ + '</div>'
879
+ + '<div class="member-list">' + members + '</div>'
880
+ + '</div></details>';
1086
881
  };
1087
882
 
1088
- const renderEmptyState = () => {
1089
- const specializationValue = specializationFilter.value;
1090
- const specializationLabel = specializationValue === 'all' ? 'all specializations' : formatLabel(specializationValue);
1091
- let timeLabel;
1092
- if (timeFilter.value === 'custom') {
1093
- const parts = [];
1094
- if (customFrom) parts.push('from ' + formatDate(customFrom));
1095
- if (customTo) parts.push('to ' + formatDate(customTo));
1096
- timeLabel = parts.length ? parts.join(' ') : 'custom range';
1097
- } else {
1098
- timeLabel = TIME_LABELS[timeFilter.value] || 'all time';
883
+ const renderEmpty = () => {
884
+ if (currentMode === 'team') {
885
+ return '<div class="empty">No teams match the current filters. Generate profiles with --team to populate this view.</div>';
1099
886
  }
1100
- return '<div class="empty">No leaderboard entries match ' + escapeHtml(timeLabel) + ' and ' + escapeHtml(specializationLabel) + '.</div>';
887
+ if (teamFilter.value !== 'all') {
888
+ return '<div class="empty">No profiles match team "' + escapeHtml(teamFilter.value) + '" with the current filters.</div>';
889
+ }
890
+ return '<div class="empty">No saved profiles match the current filters.</div>';
1101
891
  };
1102
892
 
1103
- const buildFilteredEntries = () => profiles
1104
- .map((profile) => buildEntry(profile, timeFilter.value, specializationFilter.value))
1105
- .filter(Boolean)
1106
- .sort(compareEntries);
893
+ const getEntries = () => currentMode === 'team' ? buildTeamEntries() : buildProfileEntries();
894
+
895
+ const updateModeUI = () => {
896
+ modeButtons.forEach((button) => {
897
+ const active = button.getAttribute('data-mode') === currentMode;
898
+ button.classList.toggle('is-active', active);
899
+ });
900
+ teamFilter.disabled = currentMode === 'team';
901
+ };
1107
902
 
1108
903
  const render = () => {
1109
- const entries = buildFilteredEntries();
904
+ customRange.classList.toggle('hidden', timeFilter.value !== 'custom');
905
+ updateModeUI();
906
+
907
+ const entries = getEntries();
1110
908
  const pageCount = Math.max(1, Math.ceil(entries.length / PROFILES_PER_PAGE));
1111
909
  if (currentPage > pageCount) currentPage = pageCount;
1112
910
 
1113
911
  const start = (currentPage - 1) * PROFILES_PER_PAGE;
1114
- const currentEntries = entries.slice(start, start + PROFILES_PER_PAGE);
912
+ const pageEntries = entries.slice(start, start + PROFILES_PER_PAGE);
1115
913
 
1116
- board.innerHTML = currentEntries.length
1117
- ? currentEntries.map((entry, index) => renderEntry(entry, start + index)).join('')
1118
- : renderEmptyState();
914
+ board.innerHTML = pageEntries.length
915
+ ? pageEntries.map((entry, index) => entry.kind === 'team' ? renderTeamEntry(entry, start + index) : renderProfileEntry(entry, start + index)).join('')
916
+ : renderEmpty();
1119
917
 
1120
- count.textContent = String(entries.length);
1121
-
1122
- const specializationValue = specializationFilter.value;
1123
- const specializationLabel = specializationValue === 'all' ? 'all specializations' : formatLabel(specializationValue);
1124
- let timeDescription;
1125
- if (timeFilter.value === 'custom') {
1126
- const parts = [];
1127
- if (customFrom) parts.push('from ' + formatDate(customFrom));
1128
- if (customTo) parts.push('to ' + formatDate(customTo));
1129
- timeDescription = parts.length ? parts.join(' ') : 'custom range';
1130
- } else {
1131
- timeDescription = TIME_LABELS[timeFilter.value] || 'all time';
1132
- }
1133
- filterSummary.innerHTML = '<strong>' + entries.length + '</strong> profiles for ' + escapeHtml(timeDescription) + ' across ' + escapeHtml(specializationLabel) + '.';
918
+ const subject = currentMode === 'team' ? 'teams' : 'profiles';
919
+ const scopeDescription = currentMode === 'team'
920
+ ? 'aggregated team rankings'
921
+ : (teamFilter.value === 'all' ? 'all teams' : 'team ' + teamFilter.value);
922
+ summary.innerHTML = '<strong>' + entries.length + '</strong> ' + subject + ' for ' + escapeHtml(scopeDescription) + '.';
1134
923
 
1135
924
  pagination.hidden = pageCount <= 1;
1136
925
  prevButton.disabled = currentPage === 1;
@@ -1138,62 +927,20 @@ body {
1138
927
  pageStatus.innerHTML = 'Page <strong>' + currentPage + '</strong> of ' + pageCount;
1139
928
  };
1140
929
 
1141
- const openModal = () => {
1142
- modalFromInput.value = customFrom;
1143
- modalToInput.value = customTo;
1144
- modalOverlay.hidden = false;
1145
- modalFromInput.focus();
1146
- };
1147
-
1148
- const closeModal = () => {
1149
- modalOverlay.hidden = true;
1150
- };
1151
-
1152
- timeFilter.addEventListener('change', () => {
1153
- if (timeFilter.value === 'custom') {
1154
- openModal();
1155
- } else {
1156
- previousTimeValue = timeFilter.value;
930
+ modeButtons.forEach((button) => {
931
+ button.addEventListener('click', () => {
932
+ currentMode = button.getAttribute('data-mode') || 'profile';
933
+ if (currentMode === 'team') teamFilter.value = 'all';
1157
934
  currentPage = 1;
1158
935
  render();
1159
- }
1160
- });
1161
-
1162
- modalCancelBtn.addEventListener('click', () => {
1163
- closeModal();
1164
- if (timeDropdown) timeDropdown.setValue(previousTimeValue, false);
1165
- });
1166
-
1167
- modalApplyBtn.addEventListener('click', () => {
1168
- customFrom = modalFromInput.value;
1169
- customTo = modalToInput.value;
1170
- const parts = [];
1171
- if (customFrom) parts.push(formatDate(customFrom));
1172
- if (customTo) parts.push(formatDate(customTo));
1173
- const label = timeFilter.closest('[data-filter-dropdown]')?.querySelector('[data-filter-label]');
1174
- if (label) label.textContent = parts.length ? parts.join(' \u2013 ') : 'Custom range';
1175
- closeModal();
1176
- currentPage = 1;
1177
- render();
1178
- });
1179
-
1180
- modalOverlay.addEventListener('click', (event) => {
1181
- if (event.target === modalOverlay) {
1182
- closeModal();
1183
- if (timeDropdown) timeDropdown.setValue(previousTimeValue, false);
1184
- }
1185
- });
1186
-
1187
- document.addEventListener('keydown', (event) => {
1188
- if (event.key === 'Escape' && !modalOverlay.hidden) {
1189
- closeModal();
1190
- if (timeDropdown) timeDropdown.setValue(previousTimeValue, false);
1191
- }
936
+ });
1192
937
  });
1193
938
 
1194
- specializationFilter.addEventListener('change', () => {
1195
- currentPage = 1;
1196
- render();
939
+ [timeFilter, specializationFilter, teamFilter, fromDate, toDate].forEach((element) => {
940
+ element.addEventListener('change', () => {
941
+ currentPage = 1;
942
+ render();
943
+ });
1197
944
  });
1198
945
 
1199
946
  prevButton.addEventListener('click', () => {
@@ -1203,8 +950,7 @@ body {
1203
950
  });
1204
951
 
1205
952
  nextButton.addEventListener('click', () => {
1206
- const entries = buildFilteredEntries();
1207
- const pageCount = Math.max(1, Math.ceil(entries.length / PROFILES_PER_PAGE));
953
+ const pageCount = Math.max(1, Math.ceil(getEntries().length / PROFILES_PER_PAGE));
1208
954
  if (currentPage === pageCount) return;
1209
955
  currentPage += 1;
1210
956
  render();