claude-mission-control 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,12 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
35
35
 
36
36
  *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
37
37
 
38
+ **New in 1.18.0**
39
+
40
+ - Hall of Fame (⌘K → Hall of Fame, or from Hive): your best sessions over a week, 30 days or all time, across seven awards from most commits shipped and best value per commit to the grinder and the delegator. Each award makes a share card.
41
+
42
+ ![Hall of Fame: podiums for most commits shipped, best value per commit and the grinder, each entry with its robot avatar, project, model and score, and a share card drawn for the most commits award](docs/screenshots/hall-of-fame.png)
43
+
38
44
  **New in 1.17.1**
39
45
 
40
46
  - Burn rate is now your pace over the last 30 minutes. It used to divide lifetime cost by process age, which read in the thousands per hour right after resuming an old session.
package/lib/collector.js CHANGED
@@ -711,6 +711,33 @@ class Collector {
711
711
  };
712
712
  }
713
713
 
714
+ // Every scanned session, summarised for the Hall of Fame.
715
+ fameSessions() {
716
+ const pricing = readPricing();
717
+ const out = [];
718
+ for (const g of this.raw.transcriptGroups.values()) {
719
+ if (isIgnored(g.path)) continue;
720
+ for (const m of g.sessions) {
721
+ const ts = toolSummary(m.tools);
722
+ out.push({
723
+ sessionId: m.sessionId,
724
+ title: this.titleOf(m).title,
725
+ projectName: friendlyName(g.path),
726
+ projectPath: g.path,
727
+ model: m.model || null,
728
+ cost: Math.round(costReport(combinedUsage(m), pricing).usd * 100) / 100,
729
+ startedAt: m.startedAt || null,
730
+ lastActivityAt: m.lastActivityAt,
731
+ tools: ts ? ts.total : 0,
732
+ errors: ts ? ts.errors : 0,
733
+ subagents: m.subagentCount || 0,
734
+ files: m.changeCount || 0,
735
+ });
736
+ }
737
+ }
738
+ return out;
739
+ }
740
+
714
741
  // Transcript file + title for one session, for the transcript viewer.
715
742
  findSessionFile(sessionId) {
716
743
  for (const g of this.raw.transcriptGroups.values()) {
package/lib/demo.js CHANGED
@@ -503,5 +503,37 @@ function demoProject() {
503
503
  };
504
504
  }
505
505
 
506
- module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject };
506
+
507
+ // Hall of Fame: the same shape lib/fame.js returns, over invented sessions.
508
+ function demoFame() {
509
+ const now = Date.now();
510
+ const e = (sessionId, title, projectName, model, label, ago) => ({ sessionId, title, projectName, model, label, lastActivityAt: now - ago });
511
+ const mig = ['demo2222-2222-4222-8222-222222222222', 'Migrate posts to the new block format', 'Blog Engine', 'claude-fable-5'];
512
+ const cart = ['demo6666-6666-4666-8666-666666666666', 'Cart totals rounding bug', 'Acme Storefront', 'claude-fable-5'];
513
+ const split = ['demo7777-7777-4777-8777-777777777777', 'Split the product page into blocks', 'Acme Storefront', 'claude-opus-5'];
514
+ const rss = ['demo8888-8888-4888-8888-888888888888', 'Fix the RSS feed date format', 'Blog Engine', 'claude-sonnet-5'];
515
+ const deps = ['demo9999-9999-4999-8999-999999999999', 'Bump dependencies and re-run the suite', 'Acme Storefront', 'claude-haiku-4-5'];
516
+ const onb = ['demo3333-3333-4333-8333-333333333333', 'Design the onboarding flow', 'Acme Storefront', 'claude-opus-5'];
517
+ return {
518
+ days: 30, sessions: 23, commits: 41,
519
+ awards: [
520
+ { key: 'shipped', title: 'Most commits shipped', blurb: 'commits made while the session was working', entries: [
521
+ e(...split, '11 commits', 26 * HOUR), e(...mig, '8 commits', MIN), e(...cart, '6 commits', 30 * HOUR)] },
522
+ { key: 'value', title: 'Best value', blurb: 'lowest cost per commit, three commits or more', entries: [
523
+ e(...deps, '$0.82 per commit', 3 * DAY), e(...cart, '$1.84 per commit', 30 * HOUR), e(...rss, '$2.23 per commit', 2 * DAY)] },
524
+ { key: 'grinder', title: 'The grinder', blurb: 'most tool calls in one session', entries: [
525
+ e(...mig, '388 tool calls', MIN), e(...split, '301 tool calls', 26 * HOUR), e(...onb, '164 tool calls', 3 * HOUR)] },
526
+ { key: 'clean', title: 'Cleanest run', blurb: 'fewest failed tool calls, fifty calls or more', entries: [
527
+ e(...mig, '388 calls, 0 failed', MIN), e(...rss, '92 calls, 0 failed', 2 * DAY), e(...split, '301 calls, 2 failed', 26 * HOUR)] },
528
+ { key: 'delegator', title: 'The delegator', blurb: 'most subagents handed work', entries: [
529
+ e(...mig, '4 subagents', MIN), e(...split, '3 subagents', 26 * HOUR)] },
530
+ { key: 'reach', title: 'Widest reach', blurb: 'most files edited', entries: [
531
+ e(...split, '27 files edited', 26 * HOUR), e(...mig, '19 files edited', MIN), e(...onb, '9 files edited', 3 * HOUR)] },
532
+ { key: 'spender', title: 'Big spender', blurb: 'highest list-price value', entries: [
533
+ e(...mig, '$61.20', MIN), e(...split, '$31.40', 26 * HOUR), e(...onb, '$18.20', 3 * HOUR)] },
534
+ ],
535
+ };
536
+ }
537
+
538
+ module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject, demoFame };
507
539
 
package/lib/fame.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // Hall of Fame: your best sessions over a period, by award. Pure; the server
3
+ // feeds it session summaries plus `git log` commits per project.
4
+
5
+ const PAD = 5 * 60000; // same padded activity window as cost per commit
6
+
7
+ // sessionId -> commits made inside its window. A commit inside several
8
+ // sessions' windows goes to the one that started most recently, so a long
9
+ // resumed session doesn't claim every commit made while it was open.
10
+ function commitsBySession(sessions, commitsByProject) {
11
+ const out = new Map();
12
+ const byProject = new Map();
13
+ for (const s of sessions) {
14
+ if (!byProject.has(s.projectPath)) byProject.set(s.projectPath, []);
15
+ byProject.get(s.projectPath).push(s);
16
+ }
17
+ for (const [projectPath, list] of byProject) {
18
+ for (const c of commitsByProject[projectPath] || []) {
19
+ let best = null;
20
+ for (const s of list) {
21
+ if (!s.lastActivityAt) continue;
22
+ const start = (s.startedAt || s.lastActivityAt) - PAD;
23
+ if (c.ts < start || c.ts > s.lastActivityAt + PAD) continue;
24
+ if (!best || (s.startedAt || 0) > (best.startedAt || 0)) best = s;
25
+ }
26
+ if (best) out.set(best.sessionId, (out.get(best.sessionId) || 0) + 1);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const money = (n) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`);
33
+ const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
34
+
35
+ // Each award: who qualifies, the number it ranks by, and how it reads.
36
+ const AWARDS = [
37
+ {
38
+ key: 'shipped', title: 'Most commits shipped', blurb: 'commits made while the session was working',
39
+ ok: (s) => s.commits >= 1, score: (s) => s.commits, label: (s) => plural(s.commits, 'commit'),
40
+ },
41
+ {
42
+ key: 'value', title: 'Best value', blurb: 'lowest cost per commit, three commits or more',
43
+ ok: (s) => s.commits >= 3 && s.cost >= 0.5, score: (s) => -s.cost / s.commits,
44
+ label: (s) => `${money(s.cost / s.commits)} per commit`,
45
+ },
46
+ {
47
+ key: 'grinder', title: 'The grinder', blurb: 'most tool calls in one session',
48
+ ok: (s) => s.tools >= 1, score: (s) => s.tools, label: (s) => plural(s.tools, 'tool call'),
49
+ },
50
+ {
51
+ key: 'clean', title: 'Cleanest run', blurb: 'fewest failed tool calls, fifty calls or more',
52
+ ok: (s) => s.tools >= 50, score: (s) => -(s.errors / s.tools) + s.tools / 1e7,
53
+ label: (s) => `${s.tools} calls, ${s.errors} failed`,
54
+ },
55
+ {
56
+ key: 'delegator', title: 'The delegator', blurb: 'most subagents handed work',
57
+ ok: (s) => s.subagents >= 1, score: (s) => s.subagents, label: (s) => plural(s.subagents, 'subagent'),
58
+ },
59
+ {
60
+ key: 'reach', title: 'Widest reach', blurb: 'most files edited',
61
+ ok: (s) => s.files >= 1, score: (s) => s.files, label: (s) => plural(s.files, 'file') + ' edited',
62
+ },
63
+ {
64
+ key: 'spender', title: 'Big spender', blurb: 'highest list-price value',
65
+ ok: (s) => s.cost >= 0.01, score: (s) => s.cost, label: (s) => money(s.cost),
66
+ },
67
+ ];
68
+
69
+ // sessions: [{ sessionId, title, projectName, projectPath, model, cost,
70
+ // startedAt, lastActivityAt, tools, errors, subagents, files }]
71
+ function hallOfFame(sessions, commitsByProject, { days = 30, now = Date.now(), podium = 3 } = {}) {
72
+ const cut = days ? now - days * 86400000 : 0;
73
+ const inPeriod = (sessions || []).filter((s) => (s.lastActivityAt || 0) >= cut);
74
+ const counts = commitsBySession(inPeriod, commitsByProject || {});
75
+ const rows = inPeriod.map((s) => ({ ...s, commits: counts.get(s.sessionId) || 0 }));
76
+ const awards = [];
77
+ for (const a of AWARDS) {
78
+ const ranked = rows
79
+ .filter(a.ok)
80
+ .sort((x, y) => a.score(y) - a.score(x) || (y.lastActivityAt || 0) - (x.lastActivityAt || 0))
81
+ .slice(0, podium);
82
+ if (!ranked.length) continue;
83
+ awards.push({
84
+ key: a.key,
85
+ title: a.title,
86
+ blurb: a.blurb,
87
+ entries: ranked.map((s) => ({
88
+ sessionId: s.sessionId,
89
+ title: s.title,
90
+ projectName: s.projectName,
91
+ model: s.model || null,
92
+ label: a.label(s),
93
+ lastActivityAt: s.lastActivityAt,
94
+ })),
95
+ });
96
+ }
97
+ let commits = 0;
98
+ for (const n of counts.values()) commits += n;
99
+ return { days, sessions: rows.length, commits, awards };
100
+ }
101
+
102
+ module.exports = { hallOfFame, commitsBySession, AWARDS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status — all your projects in one place.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -716,6 +716,26 @@
716
716
  .hv-foot .v { font-size: 13px; margin-top: 2px; }
717
717
  .hv-foot .v.bad { color: var(--bad); }
718
718
  .hv-note { color: var(--faint); font-size: 11px; padding: 0 20px 24px; }
719
+ .fm-sum { color: var(--muted); font-size: 12px; margin: 4px 0 14px; }
720
+ .fm-award { margin: 0 0 18px; padding: 12px 14px; background: var(--surface); border-radius: 2px; }
721
+ .fm-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
722
+ .fm-title { font-family: var(--disp-font); font-size: 18px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
723
+ .fm-blurb { color: var(--faint); font-size: 11.5px; flex: 1; min-width: 0; }
724
+ .fm-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-top: 1px solid var(--line); min-width: 0; }
725
+ .fm-head + .fm-row { border-top: none; margin-top: 6px; }
726
+ .fm-row .flap { font-family: var(--disp-font); font-weight: 600; font-size: 14px; flex: none; }
727
+ .fm-row.first .flap { color: var(--warn); font-size: 18px; }
728
+ .fm-row .hv-avatar { width: 32px; height: 32px; }
729
+ .fm-row.first .hv-avatar { width: 44px; height: 44px; }
730
+ .fm-who { flex: 1; min-width: 0; }
731
+ .fm-who .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; font-size: 13px; }
732
+ .fm-row.first .fm-who .t { font-weight: 700; }
733
+ .fm-who .t:hover { color: var(--warn); }
734
+ .fm-who .p { color: var(--faint); font-size: 11px; }
735
+ .fm-val { flex: none; font-size: 12.5px; text-align: right; }
736
+ .fm-row.first .fm-val { color: var(--warn); font-weight: 700; }
737
+ .fm-share img { width: 100%; border-radius: 6px; border: 1px solid var(--line); margin-top: 10px; }
738
+ .fm-period button.on { color: var(--warn); border-color: var(--warn); }
719
739
  @keyframes hvblink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
720
740
  .hv-avatar .eyes { transform-box: fill-box; transform-origin: center; animation: hvblink 4.5s infinite; }
721
741
  @media (prefers-reduced-motion: reduce) { .hv-avatar .eyes { animation: none; } }
@@ -947,6 +967,9 @@
947
967
  #mc-grid { grid-template-columns: 1fr; padding: 12px 16px; }
948
968
  .hv-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 12px 16px 0; }
949
969
  .hv-tile:last-child:nth-child(odd) { grid-column: span 2; }
970
+ .fm-award { padding: 10px 12px; }
971
+ .fm-head .fm-title { flex: 1; }
972
+ .fm-blurb { flex: 1 1 100%; order: 3; }
950
973
  .hv-grid { grid-template-columns: 1fr; padding: 12px 16px 16px; }
951
974
  .hv-note { padding: 0 16px 24px; }
952
975
  .hv-money .big { font-size: 32px; }
@@ -1012,6 +1035,7 @@
1012
1035
  <span class="mc-title">Hive</span>
1013
1036
  <span class="mc-sub" id="hive-sub"></span>
1014
1037
  <span class="spacer"></span>
1038
+ <button class="copy" data-fame title="Your best sessions by award">Hall of Fame →</button>
1015
1039
  <button class="d-close" id="hive-close" title="Close (Esc)">✕</button>
1016
1040
  </div>
1017
1041
  <div class="hv-tiles" id="hive-tiles"></div>
@@ -3408,6 +3432,7 @@ async function openWeekReport() {
3408
3432
  // Always drawn in the dark departures palette so shared cards look alike.
3409
3433
  const CARD = { w: 1200, h: 630, bg: '#0d1215', flap: '#151d22', flapHi: '#1c262c', split: 'rgba(0,0,0,0.45)', ink: '#e9e4d6', muted: '#93a09e', faint: '#5d6b6b', line: '#202b31', green: '#46c073', amber: '#ffb020' };
3410
3434
  let shareCanvas = null;
3435
+ let shareName = 'card.png';
3411
3436
 
3412
3437
  async function renderShareCard() {
3413
3438
  const { w, days } = weekReport;
@@ -3485,15 +3510,139 @@ async function renderShareCard() {
3485
3510
  text('npx claude-mission-control', CARD.w - 64, 578, mono(16), CARD.muted, 'right');
3486
3511
 
3487
3512
  shareCanvas = c;
3513
+ shareName = `week-with-claude-${new Date().toISOString().slice(0, 10)}.png`;
3488
3514
  return c.toDataURL('image/png');
3489
3515
  }
3490
3516
 
3517
+ // ---------- hall of fame ----------
3518
+ const fame = { days: 30, data: null };
3519
+ const FAME_PERIODS = [[7, 'week'], [30, '30 days'], [0, 'all time']];
3520
+ const famePeriod = (d) => (d === 7 ? 'the last 7 days' : d === 30 ? 'the last 30 days' : 'all time');
3521
+
3522
+ async function openFame(days = fame.days) {
3523
+ fame.days = days;
3524
+ closeHive();
3525
+ openDrawerShell('Hall of Fame', 'your best sessions, by award');
3526
+ $('#d-actions').innerHTML = `<span class="fm-period">${FAME_PERIODS.map(([d, l]) =>
3527
+ `<button class="copy${d === days ? ' on' : ''}" data-fame-days="${d}">${l}</button>`).join(' ')}</span>`;
3528
+ try {
3529
+ const r = await fetch(`/api/fame?days=${days}`);
3530
+ if (!r.ok) throw new Error();
3531
+ fame.data = await r.json();
3532
+ } catch {
3533
+ $('#d-body').innerHTML = '<div class="d-empty">Could not load the Hall of Fame.</div>';
3534
+ return;
3535
+ }
3536
+ if (fame.days !== days || $('#d-title').textContent !== 'Hall of Fame') return;
3537
+ const f = fame.data;
3538
+ if (!f.awards.length) {
3539
+ $('#d-body').innerHTML = `<div class="d-empty">No sessions in ${famePeriod(days)} yet.</div>`;
3540
+ return;
3541
+ }
3542
+ $('#d-body').innerHTML = `<div class="fm-sum">${f.sessions} session${f.sessions === 1 ? '' : 's'} · ${f.commits} commit${f.commits === 1 ? '' : 's'} in ${famePeriod(days)}. Commits count toward the session working in that repo when they were made.</div>` +
3543
+ f.awards.map((a) => `<div class="fm-award" data-award="${esc(a.key)}">
3544
+ <div class="fm-head"><span class="fm-title">${esc(a.title)}</span><span class="fm-blurb">${esc(a.blurb)}</span>
3545
+ <button class="copy" data-fame-share="${esc(a.key)}" title="Make a share card for this award">share card</button></div>
3546
+ ${a.entries.map((e, i) => `<div class="fm-row${i === 0 ? ' first' : ''}">
3547
+ <span class="flap">#${i + 1}</span>${hvAvatar(e.sessionId, '')}
3548
+ <div class="fm-who"><div class="t" data-session="${esc(e.sessionId)}" title="${esc(e.title)}">${esc(e.title)}</div>
3549
+ <div class="p">${esc(e.projectName)}${e.model ? ` · ${esc(modelShort(e.model))}` : ''} · ${esc(relTime(e.lastActivityAt))}</div></div>
3550
+ <span class="fm-val">${esc(e.label)}</span></div>`).join('')}
3551
+ <div class="fm-share"></div>
3552
+ </div>`).join('');
3553
+ }
3554
+
3555
+ // Titles are often a raw first prompt; keep home paths off a shareable image.
3556
+ function cardSafe(s) {
3557
+ return String(s || '').replace(/\/(Users|home)\/[^/\s]+/g, '~').replace(/[A-Z]:\\Users\\[^\\\s]+/gi, '~');
3558
+ }
3559
+
3560
+ function avatarImage(id) {
3561
+ const svg = hvAvatar(id, '')
3562
+ .replace('class="hv-avatar" ', 'xmlns="http://www.w3.org/2000/svg" width="240" height="240" ')
3563
+ .replaceAll('var(--bg)', CARD.bg).replaceAll('var(--ink)', CARD.ink).replaceAll('var(--warn)', CARD.amber);
3564
+ return new Promise((resolve) => {
3565
+ const img = new Image();
3566
+ img.onload = () => resolve(img);
3567
+ img.onerror = () => resolve(null);
3568
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
3569
+ });
3570
+ }
3571
+
3572
+ async function renderFameCard(award) {
3573
+ await Promise.all(['700 140px Oswald', '500 20px "JetBrains Mono"'].map((f) => document.fonts.load(f).catch(() => {})));
3574
+ const c = document.createElement('canvas');
3575
+ c.width = CARD.w * 2;
3576
+ c.height = CARD.h * 2;
3577
+ const g = c.getContext('2d');
3578
+ g.scale(2, 2);
3579
+ const oswald = (px, wt = 600) => `${wt} ${px}px Oswald, "Arial Narrow", sans-serif`;
3580
+ const mono = (px, wt = 500) => `${wt} ${px}px "JetBrains Mono", ui-monospace, monospace`;
3581
+ const text = (str, x, y, font, color, align = 'left', spacing = 0) => {
3582
+ g.font = font; g.fillStyle = color; g.textAlign = align;
3583
+ if ('letterSpacing' in g) g.letterSpacing = `${spacing}px`;
3584
+ g.fillText(str, x, y);
3585
+ if ('letterSpacing' in g) g.letterSpacing = '0px';
3586
+ };
3587
+ const fit = (str, max, font) => {
3588
+ g.font = font;
3589
+ let t = str;
3590
+ while (t.length > 1 && g.measureText(t).width > max) t = t.slice(0, -2) + '…';
3591
+ return t;
3592
+ };
3593
+ const win = award.entries[0];
3594
+
3595
+ g.fillStyle = CARD.bg; g.fillRect(0, 0, CARD.w, CARD.h);
3596
+ text('❯ HALL OF FAME', 64, 92, oswald(30), CARD.amber, 'left', 2);
3597
+ text(famePeriod(fame.days).toUpperCase(), CARD.w - 64, 90, mono(18), CARD.muted, 'right', 2);
3598
+ g.fillStyle = CARD.line; g.fillRect(64, 116, CARD.w - 128, 2);
3599
+
3600
+ text(award.title.toUpperCase(), 58, 222, oswald(84, 700), CARD.ink, 'left', 1);
3601
+ text(award.blurb, 64, 266, mono(20), CARD.muted);
3602
+
3603
+ const av = await avatarImage(win.sessionId);
3604
+ g.fillStyle = CARD.flap; g.beginPath(); g.roundRect(64, 300, CARD.w - 128, 170, 8); g.fill();
3605
+ if (av) g.drawImage(av, 88, 318, 134, 134);
3606
+ text(fit(cardSafe(win.title), CARD.w - 128 - 200 - 260, mono(26, 700)), 246, 362, mono(26, 700), CARD.ink);
3607
+ text(fit(`${win.projectName}${win.model ? ' · ' + modelShort(win.model) : ''}`, 560, mono(18)), 246, 398, mono(18), CARD.muted);
3608
+ text(win.label, CARD.w - 92, 400, oswald(50, 600), CARD.amber, 'right');
3609
+ text('#1', CARD.w - 92, 346, oswald(26, 600), CARD.muted, 'right');
3610
+
3611
+ award.entries.slice(1).forEach((e, i) => {
3612
+ const y = 516 + i * 34;
3613
+ text(`#${i + 2}`, 64, y, oswald(22, 600), CARD.faint);
3614
+ text(fit(`${cardSafe(e.title)} · ${e.projectName}`, 760, mono(17)), 110, y, mono(17), CARD.muted);
3615
+ text(e.label, CARD.w - 64, y, mono(17), CARD.muted, 'right');
3616
+ });
3617
+
3618
+ text('npx claude-mission-control', CARD.w - 64, 606, mono(16), CARD.muted, 'right');
3619
+ shareCanvas = c;
3620
+ shareName = `hall-of-fame-${award.key}.png`;
3621
+ return c.toDataURL('image/png');
3622
+ }
3623
+
3624
+ document.addEventListener('click', async (e) => {
3625
+ if (e.target.closest('[data-fame]')) { openFame(); return; }
3626
+ const p = e.target.closest('[data-fame-days]');
3627
+ if (p) { openFame(Number(p.dataset.fameDays)); return; }
3628
+ const sb = e.target.closest('[data-fame-share]');
3629
+ if (!sb || !fame.data) return;
3630
+ const award = fame.data.awards.find((a) => a.key === sb.dataset.fameShare);
3631
+ if (!award) return;
3632
+ for (const el of document.querySelectorAll('.fm-share')) el.innerHTML = '';
3633
+ const box = sb.closest('.fm-award').querySelector('.fm-share');
3634
+ box.innerHTML = '<div class="d-empty">Drawing…</div>';
3635
+ const url = await renderFameCard(award);
3636
+ box.innerHTML = `<img alt="${esc(award.title)} share card" src="${url}">
3637
+ <div style="margin-top:10px;display:flex;gap:8px"><button class="copy" data-card-download>download PNG ⇩</button><button class="copy" data-card-copy>copy image</button></div>`;
3638
+ });
3639
+
3491
3640
  document.addEventListener('click', async (e) => {
3492
3641
  if (!shareCanvas) return;
3493
3642
  if (e.target.closest('[data-card-download]')) {
3494
3643
  const a = document.createElement('a');
3495
3644
  a.href = shareCanvas.toDataURL('image/png');
3496
- a.download = `week-with-claude-${new Date().toISOString().slice(0, 10)}.png`;
3645
+ a.download = shareName;
3497
3646
  a.click();
3498
3647
  toast('Card downloaded');
3499
3648
  } else if (e.target.closest('[data-card-copy]')) {
@@ -3560,6 +3709,7 @@ function paletteItems(q) {
3560
3709
  items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
3561
3710
  items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
3562
3711
  items.push({ k: '⬢', label: 'Hive', hint: 'live sessions as characters · who is winning', run: openHive });
3712
+ items.push({ k: '★', label: 'Hall of Fame', hint: 'your best sessions by award · share cards', run: () => openFame() });
3563
3713
  items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
3564
3714
  items.push({ k: '⌗', label: 'Plugins & MCP', hint: 'what is installed, enabled, needs auth', run: openInventory });
3565
3715
  items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });
package/server.js CHANGED
@@ -16,9 +16,11 @@ const { friendlyName } = require('./lib/names');
16
16
  const cfg = require('./lib/config');
17
17
  const { isProjectMuted } = require('./lib/notify');
18
18
  const { recentCommits, linkCommitsToSessions, commitCosts } = require('./lib/gitlog');
19
+ const { hallOfFame } = require('./lib/fame');
19
20
  const { createRemoteServer } = require('./lib/remote');
20
21
  const { sessionChangeList, sessionFileDiff } = require('./lib/changes');
21
- const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject } = require('./lib/demo');
22
+ const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject, demoFame } = require('./lib/demo');
23
+ const fameCache = new Map(); // days -> { at, data }; git log per project is too slow to run per click
22
24
  const { runSelfUpdate } = require('./lib/update');
23
25
  const { readInventory } = require('./lib/inventory');
24
26
  const { readToolset } = require('./lib/toolset');
@@ -123,6 +125,7 @@ const server = http.createServer((req, res) => {
123
125
  if (url === '/api/state') return json(res, 200, demoState());
124
126
  if (url === '/api/health') return json(res, 200, { ok: true, demo: true, version: VERSION });
125
127
  if (url === '/api/stats') return json(res, 200, demoStats());
128
+ if (url === '/api/fame') return json(res, 200, demoFame());
126
129
  if (url === '/api/inventory') return json(res, 200, demoInventory());
127
130
  if (url === '/api/session') {
128
131
  const q = new URL(req.url, 'http://localhost').searchParams;
@@ -273,6 +276,24 @@ const server = http.createServer((req, res) => {
273
276
  return;
274
277
  }
275
278
 
279
+ if (url === '/api/fame') {
280
+ const raw = Number(new URL(req.url, 'http://localhost').searchParams.get('days'));
281
+ const days = [7, 30, 0].includes(raw) ? raw : 30;
282
+ const hit = fameCache.get(days);
283
+ if (hit && Date.now() - hit.at < 60000) return json(res, 200, hit.data);
284
+ const sessions = collector.fameSessions();
285
+ const cut = days ? Date.now() - days * 86400000 : 0;
286
+ const paths = [...new Set(sessions.filter((s) => s.lastActivityAt >= cut).map((s) => s.projectPath))];
287
+ Promise.all(paths.map((p) => recentCommits(p, 1000).then((c) => [p, c])))
288
+ .then((pairs) => {
289
+ const data = hallOfFame(sessions, Object.fromEntries(pairs), { days });
290
+ fameCache.set(days, { at: Date.now(), data });
291
+ json(res, 200, data);
292
+ })
293
+ .catch((e) => json(res, 500, { error: String(e.message).slice(0, 200) }));
294
+ return;
295
+ }
296
+
276
297
  if (url === '/api/inventory') {
277
298
  readInventory(collector.projectPaths())
278
299
  .then((inv) => json(res, 200, inv))