bashstats 0.2.1 → 0.2.2

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.
@@ -26,7 +26,9 @@
26
26
  --tier-silver: #C0C0C0;
27
27
  --tier-gold: #FFD700;
28
28
  --tier-diamond: #B9F2FF;
29
- --tier-obsidian: #2D1B69;
29
+ --tier-singularity: #2D1B69;
30
+ --tier-obsidian: #1a0a3e;
31
+ --tier-system-anomaly: #00ff41;
30
32
  }
31
33
 
32
34
  :root[data-theme="dark"] {
@@ -371,6 +373,148 @@
371
373
  }
372
374
  }
373
375
 
376
+ /* ===== OVERVIEW MIDDLE ROW ===== */
377
+ .overview-mid {
378
+ display: grid;
379
+ grid-template-columns: 1fr 1fr 1fr;
380
+ gap: 16px;
381
+ margin-bottom: 16px;
382
+ }
383
+
384
+ .overview-mid > .card {
385
+ display: flex;
386
+ flex-direction: column;
387
+ }
388
+
389
+ @media (max-width: 768px) {
390
+ .overview-mid {
391
+ grid-template-columns: 1fr;
392
+ }
393
+ }
394
+
395
+ .weekly-challenge-item {
396
+ display: flex;
397
+ flex-direction: column;
398
+ gap: 4px;
399
+ padding: 8px 0;
400
+ border-bottom: 1px solid var(--bg-accent);
401
+ }
402
+
403
+ .weekly-challenge-item:last-child {
404
+ border-bottom: none;
405
+ }
406
+
407
+ .weekly-challenge-desc {
408
+ font-size: 12px;
409
+ color: var(--text-primary);
410
+ }
411
+
412
+ .weekly-challenge-meta {
413
+ display: flex;
414
+ justify-content: space-between;
415
+ align-items: center;
416
+ font-size: 11px;
417
+ font-family: 'JetBrains Mono', monospace;
418
+ }
419
+
420
+ .weekly-challenge-xp {
421
+ color: var(--accent);
422
+ font-weight: 600;
423
+ }
424
+
425
+ .weekly-multiplier {
426
+ font-family: 'JetBrains Mono', monospace;
427
+ font-size: 11px;
428
+ color: var(--accent);
429
+ font-weight: 700;
430
+ }
431
+
432
+ .today-streak {
433
+ display: flex;
434
+ align-items: center;
435
+ justify-content: center;
436
+ gap: 8px;
437
+ margin-bottom: 12px;
438
+ }
439
+
440
+ .today-streak-number {
441
+ font-family: 'JetBrains Mono', monospace;
442
+ font-size: 36px;
443
+ font-weight: 700;
444
+ color: var(--accent);
445
+ }
446
+
447
+ .today-streak-label {
448
+ font-size: 12px;
449
+ color: var(--text-secondary);
450
+ line-height: 1.3;
451
+ }
452
+
453
+ .today-stat-grid {
454
+ display: grid;
455
+ grid-template-columns: 1fr 1fr;
456
+ gap: 8px;
457
+ }
458
+
459
+ .today-stat-item {
460
+ text-align: center;
461
+ padding: 8px;
462
+ background: var(--bg-accent);
463
+ border-radius: 2px;
464
+ }
465
+
466
+ .today-stat-value {
467
+ font-family: 'JetBrains Mono', monospace;
468
+ font-size: 20px;
469
+ font-weight: 700;
470
+ }
471
+
472
+ .today-stat-label {
473
+ font-size: 10px;
474
+ color: var(--text-secondary);
475
+ text-transform: uppercase;
476
+ }
477
+
478
+ .badge-progress-summary {
479
+ display: flex;
480
+ align-items: center;
481
+ gap: 12px;
482
+ margin-bottom: 12px;
483
+ }
484
+
485
+ .badge-progress-count {
486
+ font-family: 'JetBrains Mono', monospace;
487
+ font-size: 28px;
488
+ font-weight: 700;
489
+ }
490
+
491
+ .badge-progress-total {
492
+ font-size: 12px;
493
+ color: var(--text-secondary);
494
+ }
495
+
496
+ .badge-next-item {
497
+ padding: 8px;
498
+ background: var(--bg-accent);
499
+ border-radius: 2px;
500
+ border-left: 3px solid var(--accent);
501
+ }
502
+
503
+ .badge-next-name {
504
+ font-size: 13px;
505
+ font-weight: 600;
506
+ margin-bottom: 4px;
507
+ }
508
+
509
+ .badge-next-meta {
510
+ display: flex;
511
+ justify-content: space-between;
512
+ font-size: 11px;
513
+ font-family: 'JetBrains Mono', monospace;
514
+ color: var(--text-secondary);
515
+ margin-top: 4px;
516
+ }
517
+
374
518
  /* ===== RECENT BADGES ===== */
375
519
  .recent-badges-list {
376
520
  display: flex;
@@ -993,19 +1137,19 @@
993
1137
  }
994
1138
 
995
1139
  /* ===== TIER COLORS ===== */
996
- .tier-locked { color: var(--text-secondary); }
997
- .tier-bronze { color: var(--tier-bronze); }
998
- .tier-silver { color: var(--tier-silver); }
999
- .tier-gold { color: var(--tier-gold); }
1000
- .tier-diamond { color: var(--tier-diamond); }
1001
- .tier-obsidian { color: var(--tier-obsidian); }
1002
-
1003
- .tier-bg-locked { background: var(--text-secondary); }
1004
- .tier-bg-bronze { background: var(--tier-bronze); }
1005
- .tier-bg-silver { background: var(--tier-silver); }
1006
- .tier-bg-gold { background: var(--tier-gold); }
1007
- .tier-bg-diamond { background: var(--tier-diamond); }
1008
- .tier-bg-obsidian { background: var(--tier-obsidian); }
1140
+ .tier-locked { color: var(--text-secondary); }
1141
+ .tier-bronze { color: var(--tier-bronze); }
1142
+ .tier-silver { color: var(--tier-silver); }
1143
+ .tier-gold { color: var(--tier-gold); }
1144
+ .tier-diamond { color: var(--tier-diamond); }
1145
+ .tier-singularity { color: var(--tier-singularity); }
1146
+
1147
+ .tier-bg-locked { background: var(--text-secondary); }
1148
+ .tier-bg-bronze { background: var(--tier-bronze); }
1149
+ .tier-bg-silver { background: var(--tier-silver); }
1150
+ .tier-bg-gold { background: var(--tier-gold); }
1151
+ .tier-bg-diamond { background: var(--tier-diamond); }
1152
+ .tier-bg-singularity { background: var(--tier-singularity); }
1009
1153
 
1010
1154
  /* ===== REFRESH INDICATOR ===== */
1011
1155
  .refresh-indicator {
@@ -1021,6 +1165,304 @@
1021
1165
  padding: 4px 8px;
1022
1166
  box-shadow: 3px 3px 0 var(--shadow);
1023
1167
  }
1168
+
1169
+ .agent-filter {
1170
+ background: var(--bg-card);
1171
+ color: var(--text-primary);
1172
+ border: 3px solid var(--border);
1173
+ border-radius: 2px;
1174
+ padding: 4px 12px;
1175
+ font-family: 'JetBrains Mono', monospace;
1176
+ font-size: 13px;
1177
+ font-weight: 600;
1178
+ cursor: pointer;
1179
+ outline: none;
1180
+ box-shadow: 3px 3px 0 var(--shadow);
1181
+ }
1182
+ .agent-filter:focus {
1183
+ border-color: var(--accent);
1184
+ }
1185
+
1186
+ .agent-badge {
1187
+ display: inline-block;
1188
+ font-size: 0.65rem;
1189
+ padding: 1px 5px;
1190
+ border-radius: 2px;
1191
+ background: var(--bg-card);
1192
+ border: 2px solid var(--border);
1193
+ margin-left: 6px;
1194
+ vertical-align: middle;
1195
+ }
1196
+ .agent-badge.claude-code { border-color: #d97706; color: #d97706; }
1197
+ .agent-badge.gemini-cli { border-color: #4285f4; color: #4285f4; }
1198
+ .agent-badge.copilot-cli { border-color: #6e40c9; color: #6e40c9; }
1199
+ .agent-badge.opencode { border-color: #10b981; color: #10b981; }
1200
+
1201
+ .stat-na {
1202
+ opacity: 0.4;
1203
+ font-style: italic;
1204
+ }
1205
+
1206
+ .agent-breakdown-card {
1207
+ margin-top: 1rem;
1208
+ }
1209
+ .agent-bar {
1210
+ display: flex;
1211
+ align-items: center;
1212
+ gap: 8px;
1213
+ margin: 6px 0;
1214
+ }
1215
+ .agent-bar-label {
1216
+ width: 100px;
1217
+ font-size: 0.8rem;
1218
+ text-align: right;
1219
+ white-space: nowrap;
1220
+ }
1221
+ .agent-bar-track {
1222
+ flex: 1;
1223
+ background: var(--border);
1224
+ border-radius: 2px;
1225
+ height: 20px;
1226
+ overflow: hidden;
1227
+ }
1228
+ .agent-bar-fill {
1229
+ height: 100%;
1230
+ border-radius: 2px;
1231
+ transition: width 0.3s ease;
1232
+ min-width: 3px;
1233
+ }
1234
+ .agent-bar-value {
1235
+ font-size: 0.8rem;
1236
+ width: 100px;
1237
+ text-align: right;
1238
+ white-space: nowrap;
1239
+ }
1240
+
1241
+ /* ===== RANK PROGRESSION MODAL ===== */
1242
+ .rank-modal-overlay {
1243
+ position: fixed;
1244
+ inset: 0;
1245
+ background: rgba(0,0,0,0.5);
1246
+ backdrop-filter: blur(4px);
1247
+ z-index: 200;
1248
+ display: flex;
1249
+ align-items: center;
1250
+ justify-content: center;
1251
+ padding: 20px;
1252
+ }
1253
+
1254
+ .rank-modal {
1255
+ background: var(--bg-card);
1256
+ border: 3px solid var(--border);
1257
+ border-radius: 2px;
1258
+ box-shadow: 8px 8px 0 var(--shadow);
1259
+ width: 100%;
1260
+ max-width: 520px;
1261
+ max-height: 85vh;
1262
+ display: flex;
1263
+ flex-direction: column;
1264
+ }
1265
+
1266
+ .rank-modal-header {
1267
+ display: flex;
1268
+ align-items: center;
1269
+ justify-content: space-between;
1270
+ padding: 16px 20px;
1271
+ border-bottom: 3px solid var(--border);
1272
+ flex-shrink: 0;
1273
+ }
1274
+
1275
+ .rank-modal-title {
1276
+ font-family: 'JetBrains Mono', monospace;
1277
+ font-size: 16px;
1278
+ font-weight: 700;
1279
+ }
1280
+
1281
+ .rank-modal-close {
1282
+ background: none;
1283
+ border: 2px solid var(--border);
1284
+ border-radius: 2px;
1285
+ width: 32px;
1286
+ height: 32px;
1287
+ font-size: 20px;
1288
+ line-height: 1;
1289
+ cursor: pointer;
1290
+ color: var(--text-primary);
1291
+ display: flex;
1292
+ align-items: center;
1293
+ justify-content: center;
1294
+ box-shadow: 2px 2px 0 var(--shadow);
1295
+ transition: transform 0.1s, box-shadow 0.1s;
1296
+ }
1297
+
1298
+ .rank-modal-close:hover {
1299
+ transform: translate(-1px, -1px);
1300
+ box-shadow: 3px 3px 0 var(--shadow);
1301
+ }
1302
+
1303
+ .rank-modal-body {
1304
+ overflow-y: auto;
1305
+ padding: 20px;
1306
+ flex: 1;
1307
+ }
1308
+
1309
+ /* Tier sections */
1310
+ .rank-tier-section {
1311
+ margin-bottom: 24px;
1312
+ padding-left: 16px;
1313
+ border-left: 3px solid var(--text-secondary);
1314
+ }
1315
+
1316
+ .rank-tier-section:last-child {
1317
+ margin-bottom: 0;
1318
+ }
1319
+
1320
+ .rank-tier-header {
1321
+ display: flex;
1322
+ align-items: center;
1323
+ justify-content: space-between;
1324
+ margin-bottom: 12px;
1325
+ padding-bottom: 8px;
1326
+ border-bottom: 1px solid var(--bg-accent);
1327
+ }
1328
+
1329
+ .rank-tier-name {
1330
+ font-family: 'JetBrains Mono', monospace;
1331
+ font-size: 14px;
1332
+ font-weight: 700;
1333
+ }
1334
+
1335
+ .rank-tier-range {
1336
+ font-family: 'JetBrains Mono', monospace;
1337
+ font-size: 11px;
1338
+ color: var(--text-secondary);
1339
+ }
1340
+
1341
+ /* Track nodes */
1342
+ .rank-track {
1343
+ position: relative;
1344
+ padding-left: 20px;
1345
+ }
1346
+
1347
+ .rank-track::before {
1348
+ content: '';
1349
+ position: absolute;
1350
+ left: 7px;
1351
+ top: 0;
1352
+ bottom: 0;
1353
+ width: 2px;
1354
+ background: var(--bg-accent);
1355
+ }
1356
+
1357
+ .rank-node {
1358
+ position: relative;
1359
+ display: flex;
1360
+ align-items: center;
1361
+ gap: 12px;
1362
+ padding: 6px 0;
1363
+ }
1364
+
1365
+ .rank-node-dot {
1366
+ width: 16px;
1367
+ height: 16px;
1368
+ border-radius: 50%;
1369
+ border: 2px solid var(--text-secondary);
1370
+ background: var(--bg-card);
1371
+ flex-shrink: 0;
1372
+ position: relative;
1373
+ z-index: 1;
1374
+ }
1375
+
1376
+ .rank-node-dot.unlocked {
1377
+ border-color: currentColor;
1378
+ background: currentColor;
1379
+ }
1380
+
1381
+ .rank-node-dot.milestone {
1382
+ width: 20px;
1383
+ height: 20px;
1384
+ }
1385
+
1386
+ .rank-node-info {
1387
+ display: flex;
1388
+ align-items: baseline;
1389
+ gap: 8px;
1390
+ flex: 1;
1391
+ min-width: 0;
1392
+ }
1393
+
1394
+ .rank-node-number {
1395
+ font-family: 'JetBrains Mono', monospace;
1396
+ font-size: 13px;
1397
+ font-weight: 700;
1398
+ white-space: nowrap;
1399
+ }
1400
+
1401
+ .rank-node-xp {
1402
+ font-family: 'JetBrains Mono', monospace;
1403
+ font-size: 11px;
1404
+ color: var(--text-secondary);
1405
+ white-space: nowrap;
1406
+ }
1407
+
1408
+ .rank-node-label {
1409
+ font-family: 'JetBrains Mono', monospace;
1410
+ font-size: 10px;
1411
+ font-weight: 700;
1412
+ padding: 1px 6px;
1413
+ border: 2px solid;
1414
+ border-radius: 2px;
1415
+ white-space: nowrap;
1416
+ flex-shrink: 0;
1417
+ }
1418
+
1419
+ /* Current rank node */
1420
+ .rank-node.current .rank-node-dot {
1421
+ width: 22px;
1422
+ height: 22px;
1423
+ box-shadow: 0 0 0 3px var(--bg-card), 0 0 0 5px currentColor;
1424
+ animation: rankPulse 2s ease-in-out infinite;
1425
+ }
1426
+
1427
+ .rank-node.current {
1428
+ padding: 10px 0;
1429
+ }
1430
+
1431
+ /* Locked/future nodes */
1432
+ .rank-node.locked .rank-node-number {
1433
+ color: var(--text-secondary);
1434
+ }
1435
+
1436
+ .rank-node.locked .rank-node-xp {
1437
+ color: var(--text-secondary);
1438
+ opacity: 0.6;
1439
+ }
1440
+
1441
+ @keyframes rankPulse {
1442
+ 0%, 100% { box-shadow: 0 0 0 3px var(--bg-card), 0 0 0 5px currentColor; }
1443
+ 50% { box-shadow: 0 0 0 3px var(--bg-card), 0 0 8px 5px currentColor; }
1444
+ }
1445
+
1446
+ /* Clickable rank badge and rank card */
1447
+ #header-rank-badge {
1448
+ cursor: pointer;
1449
+ transition: transform 0.1s, box-shadow 0.1s;
1450
+ }
1451
+
1452
+ #header-rank-badge:hover {
1453
+ transform: translate(-2px, -2px);
1454
+ box-shadow: 5px 5px 0 var(--shadow);
1455
+ }
1456
+
1457
+ .overview-rank-clickable {
1458
+ cursor: pointer;
1459
+ transition: transform 0.1s, box-shadow 0.1s;
1460
+ }
1461
+
1462
+ .overview-rank-clickable:hover {
1463
+ transform: translate(-2px, -2px);
1464
+ box-shadow: 8px 8px 0 var(--shadow);
1465
+ }
1024
1466
  </style>
1025
1467
  </head>
1026
1468
  <body>
@@ -1036,12 +1478,13 @@
1036
1478
  </div>
1037
1479
  </div>
1038
1480
  <div class="header-right">
1039
- <div class="xp-bar-container" id="header-xp-container" style="display:none;">
1040
- <span class="xp-bar-label" id="header-xp-label">0 XP</span>
1041
- <div class="xp-bar-track">
1042
- <div class="xp-bar-fill" id="header-xp-fill" style="width:0%"></div>
1043
- </div>
1044
- </div>
1481
+ <select id="agent-filter" class="agent-filter" title="Filter by agent">
1482
+ <option value="">All Agents</option>
1483
+ <option value="claude-code">Claude Code</option>
1484
+ <option value="gemini-cli">Gemini CLI</option>
1485
+ <option value="copilot-cli">Copilot CLI</option>
1486
+ <option value="opencode">OpenCode</option>
1487
+ </select>
1045
1488
  <div class="streak-display" id="header-streak" style="display:none;">
1046
1489
  <span id="streak-icon">&#128293;</span>
1047
1490
  <span id="streak-days">0d</span>
@@ -1074,13 +1517,30 @@
1074
1517
  <div class="card-title">Recent Badges</div>
1075
1518
  <div id="overview-recent-badges" class="recent-badges-list"></div>
1076
1519
  </div>
1077
- <div class="card">
1520
+ <div class="card overview-rank-clickable" onclick="openRankModal()">
1078
1521
  <div class="card-title">Current Rank</div>
1079
1522
  <div id="overview-rank-card" class="rank-card-inner"></div>
1080
1523
  </div>
1081
1524
  </div>
1525
+ <!-- Middle row: Weekly Goals + Streak & Today + Badge Progress -->
1526
+ <div class="overview-mid">
1527
+ <div class="card">
1528
+ <div class="card-title">Weekly Goals <span class="weekly-multiplier" id="weekly-multiplier-label"></span></div>
1529
+ <div id="overview-weekly-goals"></div>
1530
+ </div>
1531
+ <div class="card">
1532
+ <div class="card-title">Streak & Today</div>
1533
+ <div id="overview-streak-today"></div>
1534
+ </div>
1535
+ <div class="card">
1536
+ <div class="card-title">Badge Progress</div>
1537
+ <div id="overview-badge-progress"></div>
1538
+ </div>
1539
+ </div>
1082
1540
  <!-- Stat cards -->
1083
1541
  <div class="stat-grid" id="overview-stat-cards"></div>
1542
+ <!-- Agent breakdown -->
1543
+ <div id="overview-agent-breakdown"></div>
1084
1544
  <!-- Sparkline -->
1085
1545
  <div class="sparkline-section card" id="overview-sparkline-card">
1086
1546
  <div class="card-title">30-Day Activity</div>
@@ -1170,6 +1630,7 @@
1170
1630
  <a class="settings-link" href="/api/achievements" target="_blank">/api/achievements</a>
1171
1631
  <a class="settings-link" href="/api/activity" target="_blank">/api/activity</a>
1172
1632
  <a class="settings-link" href="/api/sessions" target="_blank">/api/sessions</a>
1633
+ <a class="settings-link" href="/api/weekly-goals" target="_blank">/api/weekly-goals</a>
1173
1634
  </div>
1174
1635
  </div>
1175
1636
  </div>
@@ -1191,23 +1652,38 @@
1191
1652
  <span id="refresh-text"></span>
1192
1653
  </div>
1193
1654
 
1655
+ <!-- Rank Progression Modal -->
1656
+ <div id="rank-modal-overlay" class="rank-modal-overlay" style="display:none">
1657
+ <div class="rank-modal">
1658
+ <div class="rank-modal-header">
1659
+ <div class="rank-modal-title">Rank Progression</div>
1660
+ <button class="rank-modal-close" onclick="closeRankModal()">&times;</button>
1661
+ </div>
1662
+ <div class="rank-modal-body" id="rank-modal-body"></div>
1663
+ </div>
1664
+ </div>
1665
+
1194
1666
  <script>
1195
1667
  /* ===================================================================
1196
1668
  bashstats Dashboard - Single Page Application
1197
1669
  =================================================================== */
1198
1670
 
1199
1671
  // ===== STATE =====
1672
+ let selectedAgent = ''
1673
+ let agentBreakdown = null
1674
+
1200
1675
  const state = {
1201
1676
  stats: null,
1202
1677
  achievements: null,
1203
1678
  activity: null,
1204
1679
  sessions: null,
1680
+ weeklyGoals: null,
1205
1681
  lastFetch: null,
1206
1682
  };
1207
1683
 
1208
1684
  // ===== TIER HELPERS =====
1209
- const TIER_NAMES = ['Locked', 'Bronze', 'Silver', 'Gold', 'Diamond', 'Obsidian'];
1210
- const TIER_CLASSES = ['locked', 'bronze', 'silver', 'gold', 'diamond', 'obsidian'];
1685
+ const TIER_NAMES = ['Locked', 'Bronze', 'Silver', 'Gold', 'Diamond', 'Singularity'];
1686
+ const TIER_CLASSES = ['locked', 'bronze', 'silver', 'gold', 'diamond', 'singularity'];
1211
1687
 
1212
1688
  function tierClass(tier) {
1213
1689
  return TIER_CLASSES[tier] || 'locked';
@@ -1220,20 +1696,21 @@
1220
1696
  'var(--tier-silver)',
1221
1697
  'var(--tier-gold)',
1222
1698
  'var(--tier-diamond)',
1223
- 'var(--tier-obsidian)',
1699
+ 'var(--tier-singularity)',
1224
1700
  ];
1225
1701
  return colors[tier] || colors[0];
1226
1702
  }
1227
1703
 
1228
- function rankColor(rank) {
1704
+ function rankColor(rankTier) {
1229
1705
  const map = {
1230
1706
  'Bronze': 'var(--tier-bronze)',
1231
1707
  'Silver': 'var(--tier-silver)',
1232
1708
  'Gold': 'var(--tier-gold)',
1233
1709
  'Diamond': 'var(--tier-diamond)',
1234
1710
  'Obsidian': 'var(--tier-obsidian)',
1711
+ 'System Anomaly': 'var(--tier-system-anomaly)',
1235
1712
  };
1236
- return map[rank] || 'var(--tier-bronze)';
1713
+ return map[rankTier] || 'var(--tier-bronze)';
1237
1714
  }
1238
1715
 
1239
1716
  // ===== FORMAT HELPERS =====
@@ -1294,17 +1771,22 @@
1294
1771
  }
1295
1772
 
1296
1773
  async function loadAllData() {
1297
- const [stats, achievements, activity, sessions] = await Promise.all([
1298
- fetchJSON('/api/stats'),
1299
- fetchJSON('/api/achievements'),
1300
- fetchJSON('/api/activity'),
1301
- fetchJSON('/api/sessions'),
1774
+ const agentParam = selectedAgent ? '?agent=' + selectedAgent : ''
1775
+ const [stats, achievements, activity, sessions, agents, weeklyGoals] = await Promise.all([
1776
+ fetchJSON('/api/stats' + agentParam),
1777
+ fetchJSON('/api/achievements' + agentParam),
1778
+ fetchJSON('/api/activity?days=365'),
1779
+ fetchJSON('/api/sessions' + agentParam),
1780
+ fetchJSON('/api/agents'),
1781
+ fetchJSON('/api/weekly-goals'),
1302
1782
  ]);
1303
1783
 
1304
- state.stats = stats;
1305
- state.achievements = achievements;
1306
- state.activity = activity;
1307
- state.sessions = sessions;
1784
+ if (stats) state.stats = stats;
1785
+ if (achievements) state.achievements = achievements;
1786
+ if (activity) state.activity = activity;
1787
+ if (sessions) state.sessions = sessions;
1788
+ if (agents) agentBreakdown = agents;
1789
+ if (weeklyGoals) state.weeklyGoals = weeklyGoals;
1308
1790
  state.lastFetch = new Date();
1309
1791
 
1310
1792
  renderAll();
@@ -1335,6 +1817,14 @@
1335
1817
  });
1336
1818
  }
1337
1819
 
1820
+ // ===== AGENT FILTER =====
1821
+ function setupAgentFilter() {
1822
+ document.getElementById('agent-filter').addEventListener('change', (e) => {
1823
+ selectedAgent = e.target.value
1824
+ loadAllData()
1825
+ })
1826
+ }
1827
+
1338
1828
  // ===== THEME SWITCHING =====
1339
1829
  function setupTheme() {
1340
1830
  const sel = document.getElementById('settings-theme');
@@ -1376,15 +1866,8 @@
1376
1866
  const rankDot = document.getElementById('header-rank-dot');
1377
1867
  const rankText = document.getElementById('header-rank-text');
1378
1868
  rankBadge.style.display = 'inline-flex';
1379
- rankDot.style.background = rankColor(xp.rank);
1380
- rankText.textContent = xp.rank;
1381
-
1382
- const xpContainer = document.getElementById('header-xp-container');
1383
- const xpLabel = document.getElementById('header-xp-label');
1384
- const xpFill = document.getElementById('header-xp-fill');
1385
- xpContainer.style.display = 'flex';
1386
- xpLabel.textContent = formatNumber(xp.totalXP) + ' XP';
1387
- xpFill.style.width = Math.min(100, (xp.progress || 0) * 100) + '%';
1869
+ rankDot.style.background = rankColor(xp.rankTier);
1870
+ rankText.textContent = `Rank ${xp.rankNumber}`;
1388
1871
  }
1389
1872
 
1390
1873
  if (time) {
@@ -1421,13 +1904,14 @@
1421
1904
  content.style.display = 'block';
1422
1905
 
1423
1906
  const lt = state.stats.lifetime || {};
1907
+ const filesTouched = (lt.totalFilesRead || 0) + (lt.totalFilesEdited || 0) + (lt.totalFilesCreated || 0);
1424
1908
  const cards = [
1425
1909
  { label: 'Sessions', value: formatNumber(lt.totalSessions || 0), sub: 'lifetime' },
1426
1910
  { label: 'Prompts', value: formatNumber(lt.totalPrompts || 0), sub: 'messages sent' },
1427
1911
  { label: 'Tool Calls', value: formatNumber(lt.totalToolCalls || 0), sub: 'total invocations' },
1428
1912
  { label: 'Hours', value: formatHours(lt.totalDurationSeconds || 0), sub: 'coding time' },
1429
1913
  { label: 'Tokens Used', value: formatTokens(lt.totalTokens || 0), sub: 'all token types' },
1430
- { label: 'Bash Cmds', value: formatNumber(lt.totalBashCommands || lt.totalToolCalls || 0), sub: 'commands run' },
1914
+ { label: 'Files Touched', value: formatNumber(filesTouched), sub: 'read + edit + create' },
1431
1915
  ];
1432
1916
 
1433
1917
  const cardsEl = document.getElementById('overview-stat-cards');
@@ -1439,6 +1923,10 @@
1439
1923
  </div>
1440
1924
  `).join('');
1441
1925
 
1926
+ // Agent breakdown panel (only shown for "All Agents")
1927
+ const breakdownEl = document.getElementById('overview-agent-breakdown');
1928
+ if (breakdownEl) breakdownEl.innerHTML = renderAgentBreakdown();
1929
+
1442
1930
  // Sparkline
1443
1931
  renderSparkline();
1444
1932
 
@@ -1451,6 +1939,11 @@
1451
1939
 
1452
1940
  // Rank card
1453
1941
  renderOverviewRank();
1942
+
1943
+ // Middle row cards
1944
+ renderWeeklyGoals();
1945
+ renderStreakToday();
1946
+ renderBadgeProgress();
1454
1947
  }
1455
1948
 
1456
1949
  function renderEmptyOverview() {
@@ -1460,7 +1953,7 @@
1460
1953
  <div class="stat-card"><div class="stat-card-label">Tool Calls</div><div class="stat-card-number mono">0</div></div>
1461
1954
  <div class="stat-card"><div class="stat-card-label">Hours</div><div class="stat-card-number mono">0</div></div>
1462
1955
  <div class="stat-card"><div class="stat-card-label">Tokens Used</div><div class="stat-card-number mono">0</div></div>
1463
- <div class="stat-card"><div class="stat-card-label">Bash Cmds</div><div class="stat-card-number mono">0</div></div>
1956
+ <div class="stat-card"><div class="stat-card-label">Files Touched</div><div class="stat-card-number mono">0</div></div>
1464
1957
  `;
1465
1958
  }
1466
1959
 
@@ -1476,7 +1969,7 @@
1476
1969
  for (let i = 29; i >= 0; i--) {
1477
1970
  const d = new Date(today);
1478
1971
  d.setDate(d.getDate() - i);
1479
- const dateStr = d.toISOString().split('T')[0];
1972
+ const dateStr = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
1480
1973
  const found = activity.find(a => a.date === dateStr);
1481
1974
  const val = found ? (found.sessions + found.prompts + found.tool_calls) : 0;
1482
1975
  days.push({ date: dateStr, value: val });
@@ -1525,23 +2018,164 @@
1525
2018
  }
1526
2019
 
1527
2020
  const pct = Math.min(100, (xp.progress || 0) * 100);
1528
- const rc = rankColor(xp.rank);
1529
- const initial = xp.rank.charAt(0);
2021
+ const rc = rankColor(xp.rankTier);
2022
+ const rankNum = xp.rankNumber || 0;
1530
2023
  el.innerHTML = `
1531
2024
  <div class="rank-card-icon" style="border-color:${rc};background:${rc}22">
1532
- <span class="rank-card-icon-text" style="color:${rc}">${escapeHtml(initial)}</span>
2025
+ <span class="rank-card-icon-text" style="color:${rc}">${rankNum}</span>
1533
2026
  </div>
1534
2027
  <div class="rank-card-info">
1535
- <div class="rank-card-rank" style="color:${rc}">${escapeHtml(xp.rank)}</div>
2028
+ <div class="rank-card-rank" style="color:${rc}">Rank ${rankNum} &middot; ${escapeHtml(xp.rankTier)}</div>
1536
2029
  <div class="rank-card-xp">${formatNumber(xp.totalXP)} / ${formatNumber(xp.nextRankXP)} XP</div>
1537
2030
  <div class="rank-progress-track">
1538
2031
  <div class="rank-progress-fill" style="width:${pct}%;background:${rc}"></div>
1539
2032
  </div>
1540
- <div class="rank-progress-label">${pct.toFixed(1)}% to ${escapeHtml(xp.rank === 'Obsidian' ? 'max rank' : 'next rank')}</div>
2033
+ <div class="rank-progress-label">${pct.toFixed(1)}% to ${escapeHtml(rankNum >= 500 ? 'System Anomaly' : 'Rank ' + (rankNum + 1))}</div>
1541
2034
  </div>
1542
2035
  `;
1543
2036
  }
1544
2037
 
2038
+ // ===== RENDER: WEEKLY GOALS =====
2039
+ function renderWeeklyGoals() {
2040
+ const el = document.getElementById('overview-weekly-goals');
2041
+ const label = document.getElementById('weekly-multiplier-label');
2042
+ const goals = state.weeklyGoals;
2043
+
2044
+ if (!goals || !goals.challenges) {
2045
+ el.innerHTML = '<div style="font-size:12px;color:var(--text-secondary);padding:8px 0;">No weekly goals data</div>';
2046
+ if (label) label.textContent = '';
2047
+ return;
2048
+ }
2049
+
2050
+ if (label) label.textContent = goals.multiplier > 1 ? '\u00B7 ' + goals.multiplier.toFixed(1) + 'x' : '';
2051
+
2052
+ let html = goals.challenges.map(c => {
2053
+ const pct = Math.min(100, c.progress * 100);
2054
+ return '<div class="weekly-challenge-item">' +
2055
+ '<div class="weekly-challenge-desc">' + (c.completed ? '<span style="color:var(--success);font-weight:700">\u2713</span> ' : '') + escapeHtml(c.description) + '</div>' +
2056
+ '<div class="badge-progress-row">' +
2057
+ '<div class="badge-progress-track">' +
2058
+ '<div class="badge-progress-fill' + (c.completed ? ' maxed' : '') + '" style="width:' + pct + '%"></div>' +
2059
+ '</div>' +
2060
+ '<span class="badge-progress-text">' + c.current + '/' + c.threshold + '</span>' +
2061
+ '</div>' +
2062
+ '<div class="weekly-challenge-meta">' +
2063
+ '<span></span>' +
2064
+ '<span class="weekly-challenge-xp">+' + c.xpReward + ' XP</span>' +
2065
+ '</div>' +
2066
+ '</div>';
2067
+ }).join('');
2068
+
2069
+ html += '<div style="margin-top:8px;font-size:11px;color:var(--text-secondary);font-family:\'JetBrains Mono\',monospace;">' +
2070
+ goals.daysActive + '/7 days active \u00B7 ' + goals.multiplier.toFixed(1) + 'x multiplier</div>';
2071
+
2072
+ el.innerHTML = html;
2073
+ }
2074
+
2075
+ // ===== RENDER: STREAK & TODAY =====
2076
+ function renderStreakToday() {
2077
+ const el = document.getElementById('overview-streak-today');
2078
+ const time = state.stats?.time;
2079
+ const activity = state.activity || [];
2080
+
2081
+ const today = new Date();
2082
+ const todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0');
2083
+ const todayActivity = activity.find(a => a.date === todayStr);
2084
+
2085
+ const streak = time?.currentStreak || 0;
2086
+ const todaySessions = todayActivity?.sessions || 0;
2087
+ const todayPrompts = todayActivity?.prompts || 0;
2088
+ const todayTools = todayActivity?.tool_calls || 0;
2089
+ const todayHours = todayActivity ? (todayActivity.duration_seconds / 3600).toFixed(1) : '0';
2090
+
2091
+ el.innerHTML =
2092
+ '<div class="today-streak">' +
2093
+ '<span class="today-streak-number">' + streak + '</span>' +
2094
+ '<span class="today-streak-label">day streak' + (streak > 0 ? '<br>\uD83D\uDD25' : '') + '</span>' +
2095
+ '</div>' +
2096
+ '<div class="today-stat-grid">' +
2097
+ '<div class="today-stat-item"><div class="today-stat-value">' + todaySessions + '</div><div class="today-stat-label">Sessions</div></div>' +
2098
+ '<div class="today-stat-item"><div class="today-stat-value">' + todayPrompts + '</div><div class="today-stat-label">Prompts</div></div>' +
2099
+ '<div class="today-stat-item"><div class="today-stat-value">' + todayTools + '</div><div class="today-stat-label">Tools</div></div>' +
2100
+ '<div class="today-stat-item"><div class="today-stat-value">' + todayHours + '</div><div class="today-stat-label">Hours</div></div>' +
2101
+ '</div>';
2102
+ }
2103
+
2104
+ // ===== RENDER: BADGE PROGRESS =====
2105
+ function renderBadgeProgress() {
2106
+ const el = document.getElementById('overview-badge-progress');
2107
+ const badges = state.achievements?.badges || [];
2108
+
2109
+ const totalBadges = badges.length;
2110
+ const unlockedBadges = badges.filter(b => b.unlocked).length;
2111
+
2112
+ const locked = badges.filter(b => !b.unlocked && !b.secret);
2113
+ locked.sort((a, b) => (b.progress || 0) - (a.progress || 0));
2114
+ const nextBadge = locked[0];
2115
+
2116
+ const pct = totalBadges > 0 ? Math.round((unlockedBadges / totalBadges) * 100) : 0;
2117
+
2118
+ let html =
2119
+ '<div class="badge-progress-summary">' +
2120
+ '<span class="badge-progress-count">' + unlockedBadges + '</span>' +
2121
+ '<span class="badge-progress-total">/ ' + totalBadges + ' badges unlocked (' + pct + '%)</span>' +
2122
+ '</div>' +
2123
+ '<div class="badge-progress-row" style="margin-bottom:12px;">' +
2124
+ '<div class="badge-progress-track">' +
2125
+ '<div class="badge-progress-fill" style="width:' + pct + '%"></div>' +
2126
+ '</div>' +
2127
+ '</div>';
2128
+
2129
+ if (nextBadge) {
2130
+ const nextPct = Math.min(100, (nextBadge.progress || 0) * 100);
2131
+ html +=
2132
+ '<div class="badge-next-item">' +
2133
+ '<div class="badge-next-name">' + (nextBadge.icon || '\uD83C\uDFC5') + ' ' + escapeHtml(nextBadge.name) + '</div>' +
2134
+ '<div class="badge-progress-row">' +
2135
+ '<div class="badge-progress-track">' +
2136
+ '<div class="badge-progress-fill" style="width:' + nextPct + '%"></div>' +
2137
+ '</div>' +
2138
+ '<span class="badge-progress-text">' + formatNumber(nextBadge.value || 0) + '/' + formatNumber(nextBadge.nextThreshold || 0) + '</span>' +
2139
+ '</div>' +
2140
+ '<div class="badge-next-meta">' +
2141
+ '<span>' + escapeHtml(nextBadge.description || '') + '</span>' +
2142
+ '<span>' + nextPct.toFixed(0) + '%</span>' +
2143
+ '</div>' +
2144
+ '</div>';
2145
+ }
2146
+
2147
+ el.innerHTML = html;
2148
+ }
2149
+
2150
+ // ===== RENDER: AGENT BREAKDOWN =====
2151
+ function renderAgentBreakdown() {
2152
+ if (selectedAgent || !agentBreakdown || !agentBreakdown.sessionsPerAgent) return ''
2153
+ const entries = Object.entries(agentBreakdown.sessionsPerAgent).sort(([,a], [,b]) => b - a)
2154
+ if (entries.length <= 1) return ''
2155
+ const maxSessions = Math.max(...entries.map(([,c]) => c), 1)
2156
+ const agentColors = { 'claude-code': '#d97706', 'gemini-cli': '#4285f4', 'copilot-cli': '#6e40c9', 'opencode': '#10b981' }
2157
+ const agentNames = { 'claude-code': 'Claude Code', 'gemini-cli': 'Gemini CLI', 'copilot-cli': 'Copilot CLI', 'opencode': 'OpenCode' }
2158
+
2159
+ const bars = entries.map(([agent, count]) => {
2160
+ const pct = Math.round(count / maxSessions * 100)
2161
+ const hours = agentBreakdown.hoursPerAgent[agent] ?? 0
2162
+ const color = agentColors[agent] ?? 'var(--accent)'
2163
+ const name = agentNames[agent] ?? agent
2164
+ return '<div class="agent-bar">' +
2165
+ '<span class="agent-bar-label">' + escapeHtml(name) + '</span>' +
2166
+ '<div class="agent-bar-track"><div class="agent-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>' +
2167
+ '<span class="agent-bar-value">' + count + ' / ' + hours + 'h</span>' +
2168
+ '</div>'
2169
+ }).join('')
2170
+
2171
+ const favName = agentNames[agentBreakdown.favoriteAgent] ?? agentBreakdown.favoriteAgent
2172
+ return '<div class="card agent-breakdown-card">' +
2173
+ '<h3>Agent Breakdown</h3>' +
2174
+ '<p style="margin:0 0 8px;font-size:0.85rem">Favorite: <strong>' + escapeHtml(favName) + '</strong> &middot; ' + agentBreakdown.distinctAgents + ' agent' + (agentBreakdown.distinctAgents !== 1 ? 's' : '') + ' used</p>' +
2175
+ bars +
2176
+ '</div>'
2177
+ }
2178
+
1545
2179
  // ===== RENDER: STATS =====
1546
2180
  function renderOverviewHeatmap() {
1547
2181
  const wrapper = document.getElementById('overview-heatmap');
@@ -1633,10 +2267,11 @@
1633
2267
  </div>
1634
2268
  ${recent.map(s => {
1635
2269
  const sessionTokens = (s.input_tokens || 0) + (s.output_tokens || 0) + (s.cache_creation_input_tokens || 0) + (s.cache_read_input_tokens || 0);
2270
+ const agentBadge = '<span class="agent-badge ' + (s.agent || 'claude-code') + '">' + escapeHtml(s.agent || 'claude-code') + '</span>';
1636
2271
  return `
1637
2272
  <div class="session-item">
1638
2273
  <div class="session-date">${escapeHtml(formatDateTime(s.start_time || s.startTime || s.started_at || ''))}</div>
1639
- <div class="session-project">${escapeHtml(s.project || s.projectName || 'Unknown')}</div>
2274
+ <div class="session-project">${escapeHtml(s.project || s.projectName || 'Unknown')}${agentBadge}</div>
1640
2275
  <div class="session-stat">${formatDuration(s.duration_seconds || s.durationSeconds || 0)}</div>
1641
2276
  <div class="session-stat">${formatNumber(s.prompt_count || s.prompts || s.promptCount || 0)}</div>
1642
2277
  <div class="session-stat">${formatNumber(s.tool_count || s.tool_calls || s.toolCalls || 0)}</div>
@@ -1768,7 +2403,7 @@
1768
2403
  });
1769
2404
 
1770
2405
  let html = '';
1771
- const catOrder = ['volume', 'token_usage', 'tool_mastery', 'time', 'session_behavior', 'behavioral', 'prompt_patterns', 'resilience', 'error_recovery', 'tool_combos', 'shipping', 'project_dedication', 'multi_agent', 'humor', 'aspirational', 'secret'];
2406
+ const catOrder = ['volume', 'token_usage', 'tool_mastery', 'time', 'session_behavior', 'behavioral', 'prompt_patterns', 'resilience', 'error_recovery', 'tool_combos', 'shipping', 'project_dedication', 'multi_agent', 'wild_card', 'aspirational', 'secret'];
1772
2407
  const catNames = {
1773
2408
  volume: 'Volume',
1774
2409
  token_usage: 'Token Usage',
@@ -1783,7 +2418,7 @@
1783
2418
  shipping: 'Shipping & Projects',
1784
2419
  project_dedication: 'Project Dedication',
1785
2420
  multi_agent: 'Multi-Agent',
1786
- humor: 'Humor & Meta',
2421
+ wild_card: 'Wild Card',
1787
2422
  aspirational: 'Aspirational',
1788
2423
  secret: 'Secret',
1789
2424
  };
@@ -1907,6 +2542,106 @@
1907
2542
  }
1908
2543
  }
1909
2544
 
2545
+ // ===== RANK PROGRESSION MODAL =====
2546
+ const RANK_TIER_BRACKETS = [
2547
+ { tier: 'Bronze', minRank: 1, maxRank: 100, color: 'var(--tier-bronze)' },
2548
+ { tier: 'Silver', minRank: 101, maxRank: 200, color: 'var(--tier-silver)' },
2549
+ { tier: 'Gold', minRank: 201, maxRank: 300, color: 'var(--tier-gold)' },
2550
+ { tier: 'Diamond', minRank: 301, maxRank: 400, color: 'var(--tier-diamond)' },
2551
+ { tier: 'Obsidian', minRank: 401, maxRank: 499, color: 'var(--tier-obsidian)' },
2552
+ { tier: 'System Anomaly', minRank: 500, maxRank: 500, color: 'var(--tier-system-anomaly)' },
2553
+ ];
2554
+
2555
+ function xpForRankClient(rank) {
2556
+ if (rank <= 0) return 0;
2557
+ return Math.floor(10 * Math.pow(rank, 2.2));
2558
+ }
2559
+
2560
+ function openRankModal() {
2561
+ const xp = state.achievements?.xp;
2562
+ if (!xp) return;
2563
+
2564
+ const currentRank = xp.rankNumber || 1;
2565
+ const totalXP = xp.totalXP || 0;
2566
+ const body = document.getElementById('rank-modal-body');
2567
+ let html = '';
2568
+
2569
+ RANK_TIER_BRACKETS.forEach(bracket => {
2570
+ const isCurrent = currentRank >= bracket.minRank && currentRank <= bracket.maxRank;
2571
+ const isPast = currentRank > bracket.maxRank;
2572
+ const tierOpacity = !isPast && !isCurrent ? ' opacity:0.5;' : '';
2573
+ const xpStart = formatNumber(xpForRankClient(bracket.minRank));
2574
+ const xpEnd = formatNumber(xpForRankClient(bracket.maxRank));
2575
+
2576
+ html += `<div class="rank-tier-section" style="border-left-color:${bracket.color};${tierOpacity}">`;
2577
+ html += `<div class="rank-tier-header">`;
2578
+ html += `<span class="rank-tier-name" style="color:${bracket.color}">${escapeHtml(bracket.tier)}</span>`;
2579
+ html += `<span class="rank-tier-range">Rank ${bracket.minRank}-${bracket.maxRank} &middot; ${xpStart}-${xpEnd} XP</span>`;
2580
+ html += `</div>`;
2581
+ html += `<div class="rank-track" style="color:${bracket.color}">`;
2582
+
2583
+ // Determine milestone ranks: tier start, every 25th within tier, tier end, plus current rank
2584
+ const milestones = new Set();
2585
+ milestones.add(bracket.minRank);
2586
+ for (let r = bracket.minRank; r <= bracket.maxRank; r += 25) {
2587
+ milestones.add(r);
2588
+ }
2589
+ milestones.add(bracket.maxRank);
2590
+
2591
+ // Always include the current rank if it's in this tier
2592
+ if (isCurrent) {
2593
+ milestones.add(currentRank);
2594
+ }
2595
+
2596
+ const sortedMilestones = [...milestones].sort((a, b) => a - b);
2597
+
2598
+ sortedMilestones.forEach(rank => {
2599
+ const xpRequired = xpForRankClient(rank);
2600
+ const isUnlocked = rank <= currentRank;
2601
+ const isCurrentRank = rank === currentRank;
2602
+ const isMilestoneRank = rank % 25 === 0 || rank === bracket.minRank || rank === bracket.maxRank;
2603
+
2604
+ const nodeClasses = ['rank-node'];
2605
+ if (isCurrentRank) nodeClasses.push('current');
2606
+ if (!isUnlocked) nodeClasses.push('locked');
2607
+
2608
+ const dotClasses = ['rank-node-dot'];
2609
+ if (isUnlocked) dotClasses.push('unlocked');
2610
+ if (isMilestoneRank || isCurrentRank) dotClasses.push('milestone');
2611
+
2612
+ html += `<div class="${nodeClasses.join(' ')}" ${isCurrentRank ? 'id="rank-current-node"' : ''}>`;
2613
+ html += `<div class="${dotClasses.join(' ')}"></div>`;
2614
+ html += `<div class="rank-node-info">`;
2615
+ html += `<span class="rank-node-number" ${isUnlocked ? `style="color:${bracket.color}"` : ''}>Rank ${rank}</span>`;
2616
+ html += `<span class="rank-node-xp">${formatNumber(xpRequired)} XP</span>`;
2617
+ html += `</div>`;
2618
+ if (isCurrentRank) {
2619
+ html += `<span class="rank-node-label" style="color:${bracket.color};border-color:${bracket.color}">CURRENT</span>`;
2620
+ }
2621
+ html += `</div>`;
2622
+ });
2623
+
2624
+ html += `</div></div>`;
2625
+ });
2626
+
2627
+ body.innerHTML = html;
2628
+
2629
+ const overlay = document.getElementById('rank-modal-overlay');
2630
+ overlay.style.display = 'flex';
2631
+
2632
+ // Scroll to current rank after a tick to allow rendering
2633
+ requestAnimationFrame(() => {
2634
+ const currentNode = document.getElementById('rank-current-node');
2635
+ if (currentNode) {
2636
+ currentNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
2637
+ }
2638
+ });
2639
+ }
2640
+
2641
+ function closeRankModal() {
2642
+ document.getElementById('rank-modal-overlay').style.display = 'none';
2643
+ }
2644
+
1910
2645
  // ===== AUTO-REFRESH =====
1911
2646
  function startAutoRefresh() {
1912
2647
  setInterval(() => {
@@ -1917,9 +2652,25 @@
1917
2652
  // ===== INIT =====
1918
2653
  function init() {
1919
2654
  setupTabs();
2655
+ setupAgentFilter();
1920
2656
  setupTheme();
1921
2657
  loadAllData();
1922
2658
  startAutoRefresh();
2659
+
2660
+ // Rank modal: header badge click
2661
+ document.getElementById('header-rank-badge').addEventListener('click', openRankModal);
2662
+
2663
+ // Rank modal: overlay click to close
2664
+ document.getElementById('rank-modal-overlay').addEventListener('click', (e) => {
2665
+ if (e.target === e.currentTarget) closeRankModal();
2666
+ });
2667
+
2668
+ // Rank modal: escape key
2669
+ document.addEventListener('keydown', (e) => {
2670
+ if (e.key === 'Escape' && document.getElementById('rank-modal-overlay').style.display !== 'none') {
2671
+ closeRankModal();
2672
+ }
2673
+ });
1923
2674
  }
1924
2675
 
1925
2676
  document.addEventListener('DOMContentLoaded', init);