claude-rpc 0.10.0 → 0.11.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 +1 -0
- package/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/server/api.js +42 -1
- package/src/server/assets/dashboard.css +10 -0
- package/src/server/assets/dashboard.html +1 -0
- package/src/server/assets/wrapped.client.js +227 -0
- package/src/server/assets/wrapped.css +119 -0
- package/src/server/assets/wrapped.html +16 -0
- package/src/server/index.js +16 -4
- package/src/server/page.js +12 -1
- package/src/server/routes.js +6 -1
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -259,6 +259,7 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
|
|
|
259
259
|
| `session-card` | Recap card for the current session (`--out`) |
|
|
260
260
|
| `statusline` | One-line status for tmux/shell prompts (`--template`) |
|
|
261
261
|
| `mcp` | Run as an MCP server — expose your stats to Claude Code |
|
|
262
|
+
| `wrapped` | Open your animated year-in-review (Claude Wrapped) |
|
|
262
263
|
| `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
|
|
263
264
|
| `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
|
|
264
265
|
| `doctor` | Diagnostic checklist with one-line fix hints |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1141,6 +1141,7 @@ function help() {
|
|
|
1141
1141
|
['calendar', 'Year activity heatmap SVG (--out --gist)'],
|
|
1142
1142
|
['session-card', 'Recap card for the current session (--out)'],
|
|
1143
1143
|
['mcp', 'Run as an MCP server — expose your stats to Claude Code'],
|
|
1144
|
+
['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
|
|
1144
1145
|
['private', 'Mark the current directory as private (hide from Discord)'],
|
|
1145
1146
|
['public', 'Un-mark the current directory'],
|
|
1146
1147
|
['privacy', 'Show resolved visibility for the current directory'],
|
|
@@ -1218,6 +1219,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1218
1219
|
case 'calendar': await doCalendar(process.argv.slice(3)); break;
|
|
1219
1220
|
case 'session-card': await doSessionCard(process.argv.slice(3)); break;
|
|
1220
1221
|
case 'mcp': await doMcp(); break;
|
|
1222
|
+
case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
|
|
1221
1223
|
case 'private': doPrivate(); break;
|
|
1222
1224
|
case 'public': doPublic(); break;
|
|
1223
1225
|
case 'privacy': doPrivacy(); break;
|
package/src/server/api.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { basename } from 'node:path';
|
|
6
6
|
import { readState } from '../state.js';
|
|
7
|
-
import { buildVars, fillTemplate, applyIdle, framePasses } from '../format.js';
|
|
7
|
+
import { buildVars, fillTemplate, applyIdle, framePasses, humanModel } from '../format.js';
|
|
8
8
|
import { readAggregate, findLiveSessions, dayKey } from '../scanner.js';
|
|
9
9
|
import { loadConfig as loadSharedConfig } from '../config.js';
|
|
10
10
|
|
|
@@ -154,6 +154,47 @@ export function snapshot() {
|
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// Curated payload for the animated /wrapped year-in-review page. One flat
|
|
158
|
+
// object the client turns into story slides — all the headline lifetime stats.
|
|
159
|
+
const WRAPPED_WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
160
|
+
export function wrappedData() {
|
|
161
|
+
const agg = readAggregate() || {};
|
|
162
|
+
const fresh = (agg.inputTokens || 0) + (agg.outputTokens || 0);
|
|
163
|
+
const cache = (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
|
|
164
|
+
const tokens = fresh + cache;
|
|
165
|
+
const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0));
|
|
166
|
+
const top = (agg.topEditedFiles || [])[0] || null;
|
|
167
|
+
let peakWd = null;
|
|
168
|
+
for (const [k, v] of Object.entries(agg.byWeekday || {})) {
|
|
169
|
+
if (!peakWd || (v.activeMs || 0) > peakWd.ms) peakWd = { day: Number(k), ms: v.activeMs || 0 };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
generatedAt: Date.now(),
|
|
173
|
+
activeMs: agg.activeMs || 0,
|
|
174
|
+
sessions: agg.sessions || 0,
|
|
175
|
+
prompts: agg.userMessages || 0,
|
|
176
|
+
toolCalls: agg.toolCalls || 0,
|
|
177
|
+
tokens, freshTokens: fresh, cacheTokens: cache,
|
|
178
|
+
cachePct: tokens > 0 ? Math.round((cache / tokens) * 100) : 0,
|
|
179
|
+
streak: agg.streak || 0,
|
|
180
|
+
longestStreak: agg.longestStreak || 0,
|
|
181
|
+
daysSinceFirst: agg.daysSinceFirst || 0,
|
|
182
|
+
topLanguage: langs[0] ? { name: langs[0][0], edits: langs[0][1].edits || 0 } : null,
|
|
183
|
+
languages: langs.slice(0, 5).map(([name, v]) => ({ name, edits: v.edits || 0 })),
|
|
184
|
+
hotspot: top ? { name: basename(String(top.path || '')), count: top.count, daysSinceLastEdit: top.daysSinceLastEdit } : null,
|
|
185
|
+
peakHour: agg.peakHour && agg.peakHour.hour != null ? agg.peakHour.hour : null,
|
|
186
|
+
peakWeekday: peakWd ? { name: WRAPPED_WEEKDAYS[peakWd.day], hours: peakWd.ms / 3_600_000 } : null,
|
|
187
|
+
modelSplit: (agg.modelSplit || []).slice(0, 4).map((m) => ({ model: humanModel(m.model) || m.model, costPct: m.costPct || 0, tokenPct: m.tokenPct || 0 })),
|
|
188
|
+
linesAdded: agg.linesAdded || 0,
|
|
189
|
+
linesNet: agg.linesNet ?? ((agg.linesAdded || 0) - (agg.linesRemoved || 0)),
|
|
190
|
+
cost: agg.estimatedCost || 0,
|
|
191
|
+
bestDay: agg.bestDay ? { date: agg.bestDay.day, hours: (agg.bestDay.activeMs || 0) / 3_600_000 } : null,
|
|
192
|
+
uniqueFiles: agg.uniqueFiles || 0,
|
|
193
|
+
subagentRuns: agg.subagentRuns || 0,
|
|
194
|
+
notifications: agg.notifications || 0,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
export function projectDrilldown(name) {
|
|
158
199
|
const agg = readAggregate() || {};
|
|
159
200
|
const projects = agg.projects || {};
|
|
@@ -324,3 +324,13 @@ footer .pulse-dot { background: var(--grass); border: 1.5px solid var(--ink); }
|
|
|
324
324
|
.rotation-list { grid-template-columns: 1fr; }
|
|
325
325
|
.drawer { width: 100%; }
|
|
326
326
|
}
|
|
327
|
+
|
|
328
|
+
/* Wrapped link (v0.11) */
|
|
329
|
+
.wrapped-link {
|
|
330
|
+
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
|
331
|
+
font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
|
|
332
|
+
text-decoration: none; color: var(--rust, #c2491e);
|
|
333
|
+
border: 1.5px solid var(--rust, #c2491e); border-radius: 4px;
|
|
334
|
+
padding: 5px 10px; white-space: nowrap; transition: all 0.15s ease;
|
|
335
|
+
}
|
|
336
|
+
.wrapped-link:hover { background: var(--rust, #c2491e); color: var(--paper, #f4ede0); }
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
<button data-range="1y">1y</button>
|
|
30
30
|
<button data-range="all">All</button>
|
|
31
31
|
</div>
|
|
32
|
+
<a class="wrapped-link" href="/wrapped" title="Your animated year-in-review">✦ Wrapped</a>
|
|
32
33
|
<button class="theme-btn" id="theme-btn" title="Toggle theme">◐</button>
|
|
33
34
|
<span class="model" id="model">—</span>
|
|
34
35
|
<span class="status"><span class="dot" id="dot"></span><span id="statustext">—</span></span>
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
'use strict';
|
|
3
|
+
const app = document.getElementById('app');
|
|
4
|
+
|
|
5
|
+
// ── format helpers ───────────────────────────────────────────
|
|
6
|
+
const fmtNum = (n) => {
|
|
7
|
+
n = +n || 0;
|
|
8
|
+
if (n < 1000) return String(Math.round(n));
|
|
9
|
+
if (n < 1e6) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
10
|
+
if (n < 1e9) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
|
|
11
|
+
return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
|
|
12
|
+
};
|
|
13
|
+
const fmtHours = (ms) => { const h = (ms || 0) / 3.6e6; return h < 1 ? Math.round(h * 60) + 'm' : h < 10 ? h.toFixed(1) + 'h' : Math.round(h) + 'h'; };
|
|
14
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
15
|
+
const GIF = 'https://cdn.qualit.ly/';
|
|
16
|
+
|
|
17
|
+
// anim-stagger style helper
|
|
18
|
+
let _i = 0; const A = () => `style="--i:${_i++}"`;
|
|
19
|
+
|
|
20
|
+
// ── build the story slides from the wrapped payload ──────────
|
|
21
|
+
function buildSlides(d) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const hours = (d.activeMs || 0) / 3.6e6;
|
|
24
|
+
const perDay = d.prompts / Math.max(1, d.daysSinceFirst);
|
|
25
|
+
const pad2 = (h) => String(h).padStart(2, '0');
|
|
26
|
+
|
|
27
|
+
const S = (cls, body, dur) => { _i = 0; out.push({ cls, html: body, dur: dur || 5200 }); };
|
|
28
|
+
|
|
29
|
+
// 1. intro
|
|
30
|
+
S('ink', `
|
|
31
|
+
<img class="gif anim" ${A()} src="${GIF}clawd-working-building.gif" alt="" />
|
|
32
|
+
<div class="kicker anim" ${A()}>claude-rpc presents</div>
|
|
33
|
+
<div class="big anim" ${A()}>Your Year<br/>on Claude Code</div>
|
|
34
|
+
<div class="sub anim" ${A()}>${esc(d.daysSinceFirst)} days in the making · tap →</div>`, 4200);
|
|
35
|
+
|
|
36
|
+
// 2. hours
|
|
37
|
+
S('rust', `
|
|
38
|
+
<div class="kicker anim" ${A()}>you spent</div>
|
|
39
|
+
<div class="huge anim" ${A()}><span data-count="${hours}" data-fmt="1dp">0</span></div>
|
|
40
|
+
<div class="unit anim" ${A()}>hours with Claude</div>
|
|
41
|
+
<div class="sub anim" ${A()}>across ${fmtNum(d.sessions)} sessions${d.bestDay ? ` · your biggest day was ${fmtHours(d.bestDay.hours * 3.6e6)}` : ''}</div>`);
|
|
42
|
+
|
|
43
|
+
// 3. sessions + streak
|
|
44
|
+
S('gold', `
|
|
45
|
+
<div class="kicker anim" ${A()}>you opened</div>
|
|
46
|
+
<div class="huge anim" ${A()}><span data-count="${d.sessions}" data-fmt="num">0</span></div>
|
|
47
|
+
<div class="unit anim" ${A()}>sessions</div>
|
|
48
|
+
<div class="sub anim" ${A()}>longest streak: <b>${d.longestStreak} days</b> in a row${d.streak ? ` · ${d.streak} going now` : ''}</div>`);
|
|
49
|
+
|
|
50
|
+
// 4. prompts
|
|
51
|
+
S('grass', `
|
|
52
|
+
<div class="kicker anim" ${A()}>you asked</div>
|
|
53
|
+
<div class="huge anim" ${A()}><span data-count="${d.prompts}" data-fmt="num">0</span></div>
|
|
54
|
+
<div class="unit anim" ${A()}>prompts</div>
|
|
55
|
+
<div class="sub anim" ${A()}>that's about <b>${perDay < 10 ? perDay.toFixed(1) : Math.round(perDay)}</b> a day, every day</div>`);
|
|
56
|
+
|
|
57
|
+
// 5. tokens
|
|
58
|
+
S('blurple', `
|
|
59
|
+
<div class="kicker anim" ${A()}>you moved</div>
|
|
60
|
+
<div class="huge anim" ${A()}><span data-count="${d.tokens}" data-fmt="num">0</span></div>
|
|
61
|
+
<div class="unit anim" ${A()}>tokens</div>
|
|
62
|
+
<div class="sub anim" ${A()}>${d.cachePct}% reused from cache — Claude has a good memory</div>`);
|
|
63
|
+
|
|
64
|
+
// 6. top language
|
|
65
|
+
if (d.topLanguage) S('plum', `
|
|
66
|
+
<div class="kicker anim" ${A()}>you mostly spoke</div>
|
|
67
|
+
<div class="anim" ${A()}><span class="tapebadge pop">${esc(d.topLanguage.name)}</span></div>
|
|
68
|
+
<div class="sub anim" ${A()}>${fmtNum(d.topLanguage.edits)} edits — your number-one language</div>`);
|
|
69
|
+
|
|
70
|
+
// 7. hotspot file
|
|
71
|
+
if (d.hotspot) {
|
|
72
|
+
const age = d.hotspot.daysSinceLastEdit == null ? '' : d.hotspot.daysSinceLastEdit === 0 ? ' · still warm today' : ` · last touched ${d.hotspot.daysSinceLastEdit}d ago`;
|
|
73
|
+
S('ink', `
|
|
74
|
+
<div class="kicker anim" ${A()}>you kept coming back to</div>
|
|
75
|
+
<div class="big anim" ${A()} style="font-family:'JetBrains Mono',monospace;font-size:clamp(30px,9vw,68px);--i:1">${esc(d.hotspot.name)}</div>
|
|
76
|
+
<div class="sub anim" ${A()}>${fmtNum(d.hotspot.count)} edits${age}</div>`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 8. peak time
|
|
80
|
+
if (d.peakWeekday && d.peakHour != null) S('rust', `
|
|
81
|
+
<div class="kicker anim" ${A()}>you were in the zone on</div>
|
|
82
|
+
<div class="big anim" ${A()}>${esc(d.peakWeekday.name)}s</div>
|
|
83
|
+
<div class="unit anim" ${A()}>around ${pad2(d.peakHour)}:00</div>
|
|
84
|
+
<div class="sub anim" ${A()}>your most productive window</div>`);
|
|
85
|
+
|
|
86
|
+
// 9. model split
|
|
87
|
+
if (d.modelSplit && d.modelSplit.length) {
|
|
88
|
+
const rows = d.modelSplit.filter((m) => m.costPct > 0).map((m) => {
|
|
89
|
+
const pct = Math.round(m.costPct * 100);
|
|
90
|
+
return `<div class="msrow anim" ${A()}><span class="lbl">${esc(m.model)}</span><span class="mstrack"><span class="msfill" style="--w:${pct}%"></span></span><span class="pct">${pct}%</span></div>`;
|
|
91
|
+
}).join('');
|
|
92
|
+
S('paper', `
|
|
93
|
+
<div class="kicker anim" ${A()} style="--i:0">your models, by spend</div>
|
|
94
|
+
<div class="msplit">${rows}</div>
|
|
95
|
+
<div class="sub anim" ${A()} style="opacity:.7">${esc((d.modelSplit[0] || {}).model || '')} did most of the heavy lifting</div>`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 10. lines
|
|
99
|
+
if (d.linesAdded) S('grass', `
|
|
100
|
+
<div class="kicker anim" ${A()}>together you wrote</div>
|
|
101
|
+
<div class="huge anim" ${A()}><span data-count="${d.linesAdded}" data-fmt="num">0</span></div>
|
|
102
|
+
<div class="unit anim" ${A()}>lines of code</div>
|
|
103
|
+
<div class="sub anim" ${A()}>${d.linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(d.linesNet))} net after the dust settled</div>`);
|
|
104
|
+
|
|
105
|
+
// 11. finale summary
|
|
106
|
+
const cell = (k, v, cls) => `<div><div class="cellk">${k}</div><div class="cellv ${cls || ''}">${v}</div></div>`;
|
|
107
|
+
S('ink', `
|
|
108
|
+
<div class="summary">
|
|
109
|
+
<div class="card pop" style="--i:0">
|
|
110
|
+
<h2>Your Year on Claude Code</h2>
|
|
111
|
+
<div class="meta">day ${d.daysSinceFirst} · ${new Date(d.generatedAt).toISOString().slice(0, 10)}</div>
|
|
112
|
+
<div class="grid">
|
|
113
|
+
${cell('Time', fmtHours(d.activeMs), 'rust')}
|
|
114
|
+
${cell('Sessions', fmtNum(d.sessions))}
|
|
115
|
+
${cell('Prompts', fmtNum(d.prompts))}
|
|
116
|
+
${cell('Tokens', fmtNum(d.tokens))}
|
|
117
|
+
${cell('Streak', (d.longestStreak || 0) + 'd')}
|
|
118
|
+
${cell('Top lang', d.topLanguage ? esc(d.topLanguage.name) : '—')}
|
|
119
|
+
${cell('Lines', (d.linesNet >= 0 ? '+' : '−') + fmtNum(Math.abs(d.linesNet)), 'grass')}
|
|
120
|
+
${cell('Hotspot', d.hotspot ? esc(d.hotspot.name) : '—')}
|
|
121
|
+
</div>
|
|
122
|
+
<div class="foot">made with claude-rpc · github.com/rar-file/claude-rpc</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="actions">
|
|
125
|
+
<button class="btn primary" id="w-replay">↺ replay</button>
|
|
126
|
+
<a class="btn" id="w-poster" href="/api/card.svg?range=all" target="_blank">poster ↗</a>
|
|
127
|
+
<button class="btn" id="w-copy">copy link</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="hint">screenshot the card to share your wrapped</div>`, 9_000_000); // last slide: effectively no auto-advance
|
|
131
|
+
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── story engine ─────────────────────────────────────────────
|
|
136
|
+
function mount(slides) {
|
|
137
|
+
_i = 0;
|
|
138
|
+
const bars = slides.map(() => `<div class="bar"><i></i></div>`).join('');
|
|
139
|
+
const slideEls = slides.map((s) => `<section class="slide ${s.cls}">${s.html}</section>`).join('');
|
|
140
|
+
app.innerHTML = `<div class="story" id="story">
|
|
141
|
+
<div class="bars">${bars}</div>
|
|
142
|
+
<div class="brandtag">claude wrapped</div>
|
|
143
|
+
${slideEls}
|
|
144
|
+
<div class="tap left" id="tapL"></div>
|
|
145
|
+
<div class="tap right" id="tapR"></div>
|
|
146
|
+
<div class="hint" id="navhint">← → · space to pause</div>
|
|
147
|
+
</div>`;
|
|
148
|
+
|
|
149
|
+
const story = document.getElementById('story');
|
|
150
|
+
const barEls = [...story.querySelectorAll('.bar')];
|
|
151
|
+
const els = [...story.querySelectorAll('.slide')];
|
|
152
|
+
let idx = -1, timer = null, paused = false;
|
|
153
|
+
|
|
154
|
+
function runCountups(slide) {
|
|
155
|
+
slide.querySelectorAll('[data-count]').forEach((node) => {
|
|
156
|
+
const target = parseFloat(node.dataset.count) || 0, fmt = node.dataset.fmt || 'int';
|
|
157
|
+
const dur = 1300, t0 = performance.now();
|
|
158
|
+
const val = (v) => fmt === 'num' ? fmtNum(v) : fmt === '1dp' ? (v < 10 ? v.toFixed(1) : String(Math.round(v))) : String(Math.round(v));
|
|
159
|
+
function tick(t) {
|
|
160
|
+
const p = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - p, 3);
|
|
161
|
+
node.textContent = val(target * e);
|
|
162
|
+
if (p < 1) requestAnimationFrame(tick); else node.textContent = val(target);
|
|
163
|
+
}
|
|
164
|
+
requestAnimationFrame(tick);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function go(i) {
|
|
169
|
+
if (i < 0 || i >= slides.length) return;
|
|
170
|
+
idx = i; paused = false; story.classList.remove('paused');
|
|
171
|
+
els.forEach((s, k) => s.classList.toggle('active', k === i));
|
|
172
|
+
barEls.forEach((b, k) => { b.classList.remove('active', 'done'); if (k < i) b.classList.add('done'); });
|
|
173
|
+
const ab = barEls[i];
|
|
174
|
+
ab.style.setProperty('--dur', (slides[i].dur || 5200) + 'ms');
|
|
175
|
+
void ab.offsetWidth; // reflow → restart fill animation
|
|
176
|
+
ab.classList.add('active');
|
|
177
|
+
// The finale carries its own "screenshot to share" hint — hide the
|
|
178
|
+
// nav hint there so the two don't stack at the bottom edge.
|
|
179
|
+
const nh = document.getElementById('navhint');
|
|
180
|
+
if (nh) nh.style.visibility = (i === slides.length - 1) ? 'hidden' : 'visible';
|
|
181
|
+
setTimeout(() => { if (idx === i) runCountups(els[i]); }, 220);
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
if (i < slides.length - 1) timer = setTimeout(() => { if (!paused) go(idx + 1); }, slides[i].dur || 5200);
|
|
184
|
+
if (i === slides.length - 1) wireFinale();
|
|
185
|
+
}
|
|
186
|
+
const next = () => go(Math.min(idx + 1, slides.length - 1));
|
|
187
|
+
const prev = () => go(Math.max(idx - 1, 0));
|
|
188
|
+
function pauseToggle() {
|
|
189
|
+
paused = !paused; story.classList.toggle('paused', paused);
|
|
190
|
+
if (paused) clearTimeout(timer);
|
|
191
|
+
else if (idx < slides.length - 1) timer = setTimeout(() => go(idx + 1), slides[idx].dur || 5200);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
document.getElementById('tapL').onclick = prev;
|
|
195
|
+
document.getElementById('tapR').onclick = next;
|
|
196
|
+
document.addEventListener('keydown', (e) => {
|
|
197
|
+
if (e.key === 'ArrowRight') next();
|
|
198
|
+
else if (e.key === 'ArrowLeft') prev();
|
|
199
|
+
else if (e.key === ' ') { e.preventDefault(); pauseToggle(); }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let wired = false;
|
|
203
|
+
function wireFinale() {
|
|
204
|
+
if (wired) return; wired = true;
|
|
205
|
+
const r = document.getElementById('w-replay');
|
|
206
|
+
const c = document.getElementById('w-copy');
|
|
207
|
+
if (r) r.onclick = (e) => { e.stopPropagation(); go(0); };
|
|
208
|
+
if (c) c.onclick = (e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
navigator.clipboard?.writeText(location.href).then(() => { c.textContent = 'copied ✓'; setTimeout(() => c.textContent = 'copy link', 1500); }).catch(() => {});
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
go(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── boot ─────────────────────────────────────────────────────
|
|
218
|
+
fetch('/api/wrapped').then((r) => r.json()).then((d) => {
|
|
219
|
+
if (!d || !d.sessions) {
|
|
220
|
+
app.innerHTML = `<div class="boot">no data yet — run <code style="opacity:.9">claude-rpc scan</code> first, then refresh.</div>`;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
mount(buildSlides(d));
|
|
224
|
+
}).catch(() => {
|
|
225
|
+
app.innerHTML = `<div class="boot">couldn't load your year. is the daemon's <code>serve</code> running?</div>`;
|
|
226
|
+
});
|
|
227
|
+
})();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Claude Wrapped — full-screen, animated, Spotify-Wrapped-style story. */
|
|
2
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
3
|
+
:root {
|
|
4
|
+
--ink: #1a1611; --paper: #f4ede0; --rust: #c2491e; --rust3: #f08a4a;
|
|
5
|
+
--tape: #f2d76e; --grass: #4a9462; --blurple: #5865f2; --plum: #7b3f6b;
|
|
6
|
+
}
|
|
7
|
+
html, body { height: 100%; overflow: hidden; background: var(--ink); }
|
|
8
|
+
body {
|
|
9
|
+
font-family: 'Space Grotesk', system-ui, sans-serif;
|
|
10
|
+
color: var(--paper); -webkit-font-smoothing: antialiased;
|
|
11
|
+
user-select: none; -webkit-user-select: none;
|
|
12
|
+
}
|
|
13
|
+
.boot {
|
|
14
|
+
position: fixed; inset: 0; display: grid; place-items: center;
|
|
15
|
+
font-family: 'JetBrains Mono', monospace; color: var(--paper); opacity: 0.7;
|
|
16
|
+
letter-spacing: 1px;
|
|
17
|
+
}
|
|
18
|
+
.dots::after { content: ''; animation: dots 1.4s steps(4, end) infinite; }
|
|
19
|
+
@keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } }
|
|
20
|
+
|
|
21
|
+
/* ── story shell ─────────────────────────────────────────────── */
|
|
22
|
+
.story { position: fixed; inset: 0; overflow: hidden; }
|
|
23
|
+
.bars {
|
|
24
|
+
position: absolute; top: 0; left: 0; right: 0; z-index: 30;
|
|
25
|
+
display: flex; gap: 5px; padding: 14px 16px;
|
|
26
|
+
}
|
|
27
|
+
.bar { flex: 1; height: 3px; border-radius: 3px; background: rgba(255,255,255,0.28); overflow: hidden; }
|
|
28
|
+
.bar > i { display: block; height: 100%; width: 0; background: rgba(255,255,255,0.95); }
|
|
29
|
+
.bar.done > i { width: 100%; }
|
|
30
|
+
.bar.active > i { animation: fill var(--dur, 5500ms) linear forwards; }
|
|
31
|
+
.story.paused .bar.active > i { animation-play-state: paused; }
|
|
32
|
+
@keyframes fill { to { width: 100%; } }
|
|
33
|
+
|
|
34
|
+
.brandtag {
|
|
35
|
+
position: absolute; top: 26px; left: 0; right: 0; z-index: 25; text-align: center;
|
|
36
|
+
font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 3px;
|
|
37
|
+
text-transform: uppercase; opacity: 0.65; pointer-events: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* nav tap zones */
|
|
41
|
+
.tap { position: absolute; top: 0; bottom: 0; width: 35%; z-index: 20; cursor: pointer; }
|
|
42
|
+
.tap.left { left: 0; } .tap.right { right: 0; width: 65%; }
|
|
43
|
+
|
|
44
|
+
/* ── slides ──────────────────────────────────────────────────── */
|
|
45
|
+
.slide {
|
|
46
|
+
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
47
|
+
align-items: center; justify-content: center; text-align: center;
|
|
48
|
+
padding: 64px 30px; gap: 14px;
|
|
49
|
+
opacity: 0; visibility: hidden; transition: opacity 0.45s ease;
|
|
50
|
+
}
|
|
51
|
+
.slide.active { opacity: 1; visibility: visible; }
|
|
52
|
+
.slide.ink { background: var(--ink); color: var(--paper); }
|
|
53
|
+
.slide.rust { background: var(--rust); color: var(--paper); }
|
|
54
|
+
.slide.gold { background: var(--tape); color: var(--ink); }
|
|
55
|
+
.slide.grass { background: var(--grass); color: var(--paper); }
|
|
56
|
+
.slide.blurple{ background: var(--blurple);color: var(--paper); }
|
|
57
|
+
.slide.plum { background: var(--plum); color: var(--paper); }
|
|
58
|
+
.slide.paper { background: var(--paper); color: var(--ink); }
|
|
59
|
+
.slide::after { /* subtle dot grid */
|
|
60
|
+
content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0.10;
|
|
61
|
+
background-image: radial-gradient(currentColor 1px, transparent 1.4px);
|
|
62
|
+
background-size: 26px 26px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* entrance animation — children with .anim rise + fade in, staggered */
|
|
66
|
+
.slide .anim { opacity: 0; transform: translateY(26px); }
|
|
67
|
+
.slide.active .anim { animation: rise 0.7s cubic-bezier(.2,.7,.2,1) forwards; animation-delay: calc(var(--i, 0) * 0.13s + 0.1s); }
|
|
68
|
+
@keyframes rise { to { opacity: 1; transform: none; } }
|
|
69
|
+
.slide.active .pop { animation: pop 0.6s cubic-bezier(.2,1.4,.4,1) forwards; animation-delay: calc(var(--i, 0) * 0.13s + 0.15s); }
|
|
70
|
+
@keyframes pop { 0% { opacity: 0; transform: scale(0.6); } 100% { opacity: 1; transform: scale(1); } }
|
|
71
|
+
|
|
72
|
+
.kicker { font-family: 'JetBrains Mono', monospace; font-size: clamp(13px, 3.6vw, 17px); letter-spacing: 1px; opacity: 0.85; max-width: 16ch; line-height: 1.5; }
|
|
73
|
+
.huge { font-weight: 800; font-size: clamp(64px, 22vw, 168px); line-height: 0.92; letter-spacing: -4px; }
|
|
74
|
+
.big { font-weight: 800; font-size: clamp(40px, 13vw, 104px); line-height: 0.98; letter-spacing: -2px; }
|
|
75
|
+
.sub { font-family: 'JetBrains Mono', monospace; font-size: clamp(13px, 3.6vw, 16px); opacity: 0.8; max-width: 26ch; line-height: 1.6; }
|
|
76
|
+
.unit { font-weight: 700; font-size: clamp(22px, 7vw, 44px); letter-spacing: -1px; }
|
|
77
|
+
.tapebadge {
|
|
78
|
+
display: inline-block; background: var(--tape); color: var(--ink);
|
|
79
|
+
font-family: 'JetBrains Mono', monospace; font-weight: 700; letter-spacing: 1px;
|
|
80
|
+
padding: 7px 14px; border: 2px solid var(--ink); transform: rotate(-2deg);
|
|
81
|
+
font-size: clamp(15px, 4.5vw, 22px); text-transform: uppercase;
|
|
82
|
+
}
|
|
83
|
+
.gif { width: clamp(96px, 28vw, 150px); image-rendering: auto; }
|
|
84
|
+
|
|
85
|
+
/* model split bars */
|
|
86
|
+
.msplit { width: min(86vw, 420px); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
|
87
|
+
.msrow { display: flex; align-items: center; gap: 10px; font-family: 'JetBrains Mono', monospace; font-size: 13px; }
|
|
88
|
+
.msrow .lbl { width: 88px; text-align: right; opacity: 0.9; }
|
|
89
|
+
.mstrack { flex: 1; height: 14px; background: rgba(0,0,0,0.18); border-radius: 8px; overflow: hidden; }
|
|
90
|
+
.slide.gold .mstrack, .slide.paper .mstrack { background: rgba(0,0,0,0.12); }
|
|
91
|
+
.msfill { display: block; height: 100%; width: 0; background: currentColor; border-radius: 8px;
|
|
92
|
+
transition: width 0.95s cubic-bezier(.2,.7,.2,1) 0.35s; }
|
|
93
|
+
.slide.active .msfill { width: var(--w, 0%); }
|
|
94
|
+
.msrow .pct { width: 44px; opacity: 0.85; }
|
|
95
|
+
|
|
96
|
+
/* ── finale summary ──────────────────────────────────────────── */
|
|
97
|
+
.summary { width: min(92vw, 460px); }
|
|
98
|
+
.card {
|
|
99
|
+
background: var(--paper); color: var(--ink); border: 2px solid var(--ink);
|
|
100
|
+
box-shadow: 8px 9px 0 rgba(0,0,0,0.35); padding: 24px 24px 20px; text-align: left;
|
|
101
|
+
}
|
|
102
|
+
.card h2 { font-size: clamp(26px, 8vw, 36px); letter-spacing: -1.5px; margin-bottom: 2px; }
|
|
103
|
+
.card .meta { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #5c5147; margin-bottom: 16px; }
|
|
104
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 18px; }
|
|
105
|
+
.cellk { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; letter-spacing: 1.5px; color: #5c5147; text-transform: uppercase; }
|
|
106
|
+
.cellv { font-weight: 800; font-size: clamp(20px, 6vw, 26px); letter-spacing: -0.5px; line-height: 1.1; }
|
|
107
|
+
.cellv.rust { color: var(--rust); } .cellv.grass { color: var(--grass); }
|
|
108
|
+
.foot { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8a7c6d; margin-top: 16px; border-top: 1px dashed #c9bca3; padding-top: 10px; }
|
|
109
|
+
.actions { display: flex; gap: 10px; justify-content: center; margin-top: 22px; flex-wrap: wrap; }
|
|
110
|
+
.btn {
|
|
111
|
+
font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 13px;
|
|
112
|
+
padding: 11px 18px; border: 2px solid var(--paper); background: transparent; color: var(--paper);
|
|
113
|
+
cursor: pointer; letter-spacing: 0.5px; border-radius: 2px;
|
|
114
|
+
}
|
|
115
|
+
.btn.primary { background: var(--paper); color: var(--ink); }
|
|
116
|
+
.btn:hover { transform: translateY(-1px); }
|
|
117
|
+
|
|
118
|
+
.hint { position: absolute; bottom: 16px; left: 0; right: 0; text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; opacity: 0.5; z-index: 25; pointer-events: none; }
|
|
119
|
+
@media (max-width: 420px) { .grid { gap: 12px 14px; } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>Your Year on Claude Code · claude-rpc</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>{{STYLES}}</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="app"><div class="boot">building your year<span class="dots"></span></div></div>
|
|
14
|
+
<script>{{SCRIPT}}</script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/src/server/index.js
CHANGED
|
@@ -16,11 +16,12 @@ import { exec } from 'node:child_process';
|
|
|
16
16
|
import { ROUTES, JSON_HEADERS } from './routes.js';
|
|
17
17
|
import { projectDrilldown, dayDetail } from './api.js';
|
|
18
18
|
import { sseClients, watchSources } from './sse.js';
|
|
19
|
-
import { buildHtml } from './page.js';
|
|
19
|
+
import { buildHtml, buildWrappedHtml } from './page.js';
|
|
20
20
|
|
|
21
21
|
// Pre-compose the HTML once at startup — the only dynamic bit is the port
|
|
22
22
|
// (used in a breadcrumb), which is fixed for the life of the daemon.
|
|
23
23
|
const HTML = buildHtml({ port: Number(process.env.CLAUDE_RPC_PORT) || 47474 });
|
|
24
|
+
const WRAPPED_HTML = buildWrappedHtml();
|
|
24
25
|
|
|
25
26
|
const PORT = Number(process.env.CLAUDE_RPC_PORT) || 47474;
|
|
26
27
|
|
|
@@ -70,6 +71,13 @@ const server = createServer((req, res) => {
|
|
|
70
71
|
const handler = ROUTES.get(key);
|
|
71
72
|
if (handler) return handler(req, res, { query });
|
|
72
73
|
|
|
74
|
+
// Animated year-in-review.
|
|
75
|
+
if (req.method === 'GET' && (path === '/wrapped' || path === '/wrapped.html')) {
|
|
76
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
77
|
+
res.end(WRAPPED_HTML);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
// Page.
|
|
74
82
|
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
75
83
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
@@ -83,9 +91,13 @@ const server = createServer((req, res) => {
|
|
|
83
91
|
watchSources();
|
|
84
92
|
|
|
85
93
|
server.listen(PORT, '127.0.0.1', () => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
95
|
+
// `claude-rpc wrapped` sets CLAUDE_RPC_OPEN_PATH=/wrapped to land on the
|
|
96
|
+
// animated year-in-review instead of the dashboard.
|
|
97
|
+
const openPath = process.env.CLAUDE_RPC_OPEN_PATH || '';
|
|
98
|
+
const url = base + openPath;
|
|
99
|
+
console.log(`◆ Claude RPC dashboard: ${base}`);
|
|
100
|
+
console.log(` ${openPath ? 'opening ' + url + ' · ' : ''}Ctrl-C to stop.`);
|
|
89
101
|
if (!process.env.CLAUDE_RPC_NO_OPEN) {
|
|
90
102
|
const opener = process.platform === 'win32' ? `start "" "${url}"`
|
|
91
103
|
: process.platform === 'darwin' ? `open "${url}"`
|
package/src/server/page.js
CHANGED
|
@@ -19,6 +19,10 @@ const TEMPLATE = loadAsset('dashboard.html');
|
|
|
19
19
|
const STYLES = loadAsset('dashboard.css');
|
|
20
20
|
const SCRIPT = loadAsset('dashboard.client.js');
|
|
21
21
|
|
|
22
|
+
const WRAPPED_TEMPLATE = loadAsset('wrapped.html');
|
|
23
|
+
const WRAPPED_STYLES = loadAsset('wrapped.css');
|
|
24
|
+
const WRAPPED_SCRIPT = loadAsset('wrapped.client.js');
|
|
25
|
+
|
|
22
26
|
function buildHtml({ port }) {
|
|
23
27
|
// Replacer FUNCTIONS, not strings: the client JS is full of `$(id)` DOM
|
|
24
28
|
// accessors and the CSS could carry `$`, both of which String.prototype
|
|
@@ -30,4 +34,11 @@ function buildHtml({ port }) {
|
|
|
30
34
|
.replaceAll('{{PORT}}', String(port));
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
// The animated /wrapped year-in-review. Same compose-once pattern.
|
|
38
|
+
function buildWrappedHtml() {
|
|
39
|
+
return WRAPPED_TEMPLATE
|
|
40
|
+
.replace('{{STYLES}}', () => WRAPPED_STYLES)
|
|
41
|
+
.replace('{{SCRIPT}}', () => WRAPPED_SCRIPT);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { buildHtml, buildWrappedHtml };
|
package/src/server/routes.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readAggregate } from '../scanner.js';
|
|
|
8
8
|
import { generateInsights } from '../insights.js';
|
|
9
9
|
import { badgeSvg } from '../badge.js';
|
|
10
10
|
import { renderCard } from '../card.js';
|
|
11
|
-
import { snapshot, windowedAggregate, aggregateToCsv } from './api.js';
|
|
11
|
+
import { snapshot, windowedAggregate, aggregateToCsv, wrappedData } from './api.js';
|
|
12
12
|
|
|
13
13
|
export const JSON_HEADERS = {
|
|
14
14
|
'content-type': 'application/json',
|
|
@@ -22,6 +22,11 @@ ROUTES.set('GET /api/state', (req, res) => {
|
|
|
22
22
|
res.end(JSON.stringify(snapshot()));
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
ROUTES.set('GET /api/wrapped', (req, res) => {
|
|
26
|
+
res.writeHead(200, JSON_HEADERS);
|
|
27
|
+
res.end(JSON.stringify(wrappedData()));
|
|
28
|
+
});
|
|
29
|
+
|
|
25
30
|
ROUTES.set('GET /api/aggregate', (req, res, { query }) => {
|
|
26
31
|
const range = query.range || '90d';
|
|
27
32
|
const agg = readAggregate();
|