claude-dependency-test 1.0.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/.claude-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/assets/card.js +696 -0
- package/assets/fonts/IBMPlexMono-400.woff2 +0 -0
- package/assets/fonts/OFL-IBMPlexMono.txt +93 -0
- package/assets/fonts/OFL-VT323.txt +93 -0
- package/assets/fonts/VT323-400.woff2 +0 -0
- package/assets/report.template.html +181 -0
- package/config/scoring.json +203 -0
- package/package.json +29 -0
- package/scripts/diagnose.mjs +168 -0
- package/scripts/lib/collect.mjs +206 -0
- package/scripts/lib/metrics.mjs +267 -0
- package/scripts/lib/publish.mjs +77 -0
- package/scripts/lib/report.mjs +277 -0
- package/scripts/lib/score.mjs +69 -0
package/assets/card.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// Claude Dependency Test: shared card model + renderer.
|
|
2
|
+
// Plain ES module with no dependencies. Used by the Node script (share URL), the local
|
|
3
|
+
// report.html (inlined) and the website (served as /card.js). Keep it DOM-free at top level.
|
|
4
|
+
// The site keeps a byte-identical copy in site/public/card.js (npm run sync-card).
|
|
5
|
+
|
|
6
|
+
export const CARD_VERSION = 1;
|
|
7
|
+
export const CARD_WIDTH = 1200;
|
|
8
|
+
export const CARD_HEIGHT = 630;
|
|
9
|
+
|
|
10
|
+
export const STAGES = {
|
|
11
|
+
1: { name: 'Casual User', prognosis: 'Healthy relationship detected. Suspiciously healthy. Monitor for relapse.' },
|
|
12
|
+
2: { name: 'Regular', prognosis: 'Mild attachment. Patient can still write a regex unassisted, on a good day.' },
|
|
13
|
+
3: { name: 'Dependent', prognosis: 'Patient says "let me just ask Claude" several times a day. Stable, cheerful.' },
|
|
14
|
+
4: { name: 'Chronic', prognosis: 'The terminal tab is never closed. Prognosis: alarmingly productive.' },
|
|
15
|
+
5: { name: 'Terminal', prognosis: 'Patient lives in the terminal. Literally. No known cure, no regrets.' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// "Archetypes" are fake medical conditions, each with a made-up diagnostic code.
|
|
19
|
+
// (The field is still called `archetype` in data and URLs.)
|
|
20
|
+
export const STAGES_FR = {
|
|
21
|
+
1: { name: 'Usage occasionnel', prognosis: 'Relation saine détectée. Étrangement saine. Surveiller les rechutes.' },
|
|
22
|
+
2: { name: 'Régulier', prognosis: "Attachement léger. Le patient sait encore écrire une regex seul, les bons jours." },
|
|
23
|
+
3: { name: 'Dépendant', prognosis: "Le patient dit « je demande à Claude » plusieurs fois par jour. Stable, de bonne humeur." },
|
|
24
|
+
4: { name: 'Chronique', prognosis: "L'onglet du terminal ne se ferme jamais. Pronostic : productivité alarmante." },
|
|
25
|
+
5: { name: 'Terminal', prognosis: 'Le patient vit dans le terminal. Littéralement. Aucun remède connu, aucun regret.' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ARCHETYPES_FR = {
|
|
29
|
+
'recreational-use': { name: 'Usage récréatif', tagline: "Consomme Claude en société. Peut arrêter quand il veut. En théorie.", alarm: '● STABLE' },
|
|
30
|
+
'deep-focus': { name: 'Syndrome de concentration profonde chronique', tagline: 'Des sessions si longues qu\'elles mériteraient un entracte.', alarm: '▲ SESSIONS LONGUES' },
|
|
31
|
+
'streak-dependency': { name: 'Syndrome de dépendance à la série', tagline: "N'a pas manqué un seul jour. Claude a maintenant les clés de l'appartement.", alarm: '▲ SÉRIE ÉLEVÉE' },
|
|
32
|
+
'session-overextension': { name: 'Trouble de la session à rallonge', tagline: 'Les sessions se terminent quand la batterie du portable lâche.', alarm: '▲ SESSION TROP LONGUE' },
|
|
33
|
+
'middle-manager': { name: 'Syndrome du manager intermédiaire', tagline: "Dirige une équipe d'IA. N'a jamais rencontré aucun de ses membres.", alarm: '▲ EFFECTIF ÉLEVÉ' },
|
|
34
|
+
'hypergraphia': { name: 'Hypergraphie par procuration', tagline: 'Claude écrit un roman par jour pour ce patient.', alarm: '▲ PRODUCTION ÉLEVÉE' },
|
|
35
|
+
'prompt-hyperactivity': { name: "Trouble de l'hyperactivité promptique", tagline: 'Tape plus vite que Claude ne dit « Vous avez tout à fait raison ».', alarm: '▲ DÉBIT DE PROMPTS ÉLEVÉ' },
|
|
36
|
+
'limit-collision': { name: 'Collision chronique avec la limite', tagline: "Tutoie l'écran de limite d'usage.", alarm: '▲ LIMITE ATTEINTE' },
|
|
37
|
+
'opus-affluenza': { name: 'Affluenza Opus', tagline: 'Seul le meilleur modèle fera l\'affaire.', alarm: '▲ DOSE D\'OPUS ÉLEVÉE' },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const ARCHETYPES = {
|
|
41
|
+
'recreational-use': { name: 'Recreational Use', code: 'CDT-00.1', tagline: 'Takes Claude socially. Can stop anytime. Probably.', emoji: '🍵', alarm: '● STABLE' },
|
|
42
|
+
'deep-focus': { name: 'Chronic Deep-Focus Syndrome', code: 'CDT-25.7', tagline: 'Sessions so long they need an intermission.', emoji: '⏳', alarm: '▲ SESSION LENGTH HIGH' },
|
|
43
|
+
'streak-dependency': { name: 'Streak Dependency Syndrome', code: 'CDT-20.4', tagline: 'Has not missed a day. Claude now has a key to the apartment.', emoji: '🔥', alarm: '▲ STREAK HIGH' },
|
|
44
|
+
'session-overextension': { name: 'Session Overextension Disorder', code: 'CDT-10.2', tagline: 'Sessions end when the laptop battery does.', emoji: '🔋', alarm: '▲ SESSION TOO LONG' },
|
|
45
|
+
'middle-manager': { name: 'Middle-Manager Syndrome', code: 'CDT-10.6', tagline: 'Manages a team of AIs. Has never met any of them.', emoji: '🧑💼', alarm: '▲ HEADCOUNT HIGH' },
|
|
46
|
+
'hypergraphia': { name: 'Hypergraphia by Proxy', code: 'CDT-10.9', tagline: 'Claude writes a novel a day for this patient.', emoji: '📚', alarm: '▲ OUTPUT HIGH' },
|
|
47
|
+
'prompt-hyperactivity': { name: 'Prompt Hyperactivity Disorder', code: 'CDT-15.9', tagline: 'Types faster than Claude can say "You\'re absolutely right".', emoji: '⚡', alarm: '▲ PROMPT RATE HIGH' },
|
|
48
|
+
'limit-collision': { name: 'Chronic Limit Collision', code: 'CDT-10.8', tagline: 'On a first-name basis with the usage limit screen.', emoji: '🚧', alarm: '▲ LIMIT REACHED' },
|
|
49
|
+
'opus-affluenza': { name: 'Opus Affluenza', code: 'CDT-05.5', tagline: 'Only the finest model will do.', emoji: '🍷', alarm: '▲ OPUS DOSE HIGH' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Symptom keys used in share URLs, in canonical display order.
|
|
53
|
+
// `criterion` links a symptom to the scoring criterion it illustrates.
|
|
54
|
+
export const SYMPTOMS = [
|
|
55
|
+
{ key: 'h', criterion: 'hours', label: 'Active hours / active day', max: 24, decimals: 1, fmt: (s) => `${s.h.toFixed(1)} h` },
|
|
56
|
+
{ key: 'ad', criterion: 'presence', label: 'Days active', max: 100, fmt: (s) => (s.sk != null ? `${s.ad}% · ${s.sk}-day streak` : `${s.ad}%`) },
|
|
57
|
+
{ key: 'sk', criterion: 'streak', max: 3650, hidden: true },
|
|
58
|
+
{ key: 'n', criterion: 'night', label: 'Prompts between 0-5 AM', max: 100, fmt: (s) => `${s.n}%` },
|
|
59
|
+
{ key: 'p', criterion: 'intensity', label: 'Prompts / active day', max: 100000, fmt: (s) => `${s.p}` },
|
|
60
|
+
{ key: 'w', criterion: 'weekend', label: 'Weekend activity', max: 100, fmt: (s) => `${s.w}%` },
|
|
61
|
+
{ key: 'l', criterion: 'limits', label: 'Usage limits hit', max: 10000, fmt: (s) => `${s.l}×` },
|
|
62
|
+
{ key: 'ls', criterion: 'marathon', label: 'Longest session', max: 240, decimals: 1, fmt: (s) => `${s.ls.toFixed(1)} h` },
|
|
63
|
+
{ key: 'sa', criterion: 'delegation', label: 'Subagents hired', max: 1000000, fmt: (s) => `${s.sa}` },
|
|
64
|
+
{ key: 'wd', criterion: 'output', label: 'Words by Claude / day', max: 100000000, fmt: (s) => compact(s.wd) },
|
|
65
|
+
{ key: 'o', criterion: 'opus', label: 'Opus share', max: 100, fmt: (s) => `${s.o}%` },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const intIn = (raw, lo, hi) => {
|
|
69
|
+
if (raw == null || !/^\d{1,9}$/.test(raw)) return null;
|
|
70
|
+
const n = Number(raw);
|
|
71
|
+
return n >= lo && n <= hi ? n : null;
|
|
72
|
+
};
|
|
73
|
+
const decIn = (raw, lo, hi) => {
|
|
74
|
+
if (raw == null || !/^\d{1,3}(\.\d)?$/.test(raw)) return null;
|
|
75
|
+
const n = Number(raw);
|
|
76
|
+
return n >= lo && n <= hi ? n : null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** card: { score, stage, archetype, days, sym: { h?, ad?, sk?, n?, p?, w?, l?, o? } } */
|
|
80
|
+
export function encodeCard(card) {
|
|
81
|
+
const q = new URLSearchParams();
|
|
82
|
+
q.set('s', String(card.score));
|
|
83
|
+
q.set('st', String(card.stage));
|
|
84
|
+
q.set('a', card.archetype);
|
|
85
|
+
if (card.days) q.set('d', String(card.days));
|
|
86
|
+
for (const def of SYMPTOMS) {
|
|
87
|
+
const v = card.sym[def.key];
|
|
88
|
+
if (v == null) continue;
|
|
89
|
+
q.set(def.key, def.decimals ? v.toFixed(def.decimals) : String(Math.round(v)));
|
|
90
|
+
}
|
|
91
|
+
q.set('v', String(CARD_VERSION));
|
|
92
|
+
return q.toString();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Strict parser for untrusted query strings. Returns null when the core fields are invalid. */
|
|
96
|
+
export function decodeCard(search) {
|
|
97
|
+
const q = new URLSearchParams(search);
|
|
98
|
+
const score = intIn(q.get('s'), 0, 100);
|
|
99
|
+
const stage = intIn(q.get('st'), 1, 5);
|
|
100
|
+
const archetype = q.get('a');
|
|
101
|
+
if (score == null || stage == null || !Object.prototype.hasOwnProperty.call(ARCHETYPES, archetype)) return null;
|
|
102
|
+
const sym = {};
|
|
103
|
+
for (const def of SYMPTOMS) {
|
|
104
|
+
const v = def.decimals ? decIn(q.get(def.key), 0, def.max) : intIn(q.get(def.key), 0, def.max);
|
|
105
|
+
if (v != null) sym[def.key] = v;
|
|
106
|
+
}
|
|
107
|
+
return { score, stage, archetype, days: intIn(q.get('d'), 1, 3650), sym };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function symptomRows(card) {
|
|
111
|
+
return SYMPTOMS.filter((d) => !d.hidden && card.sym[d.key] != null).map((d) => ({ key: d.key, label: d.label, value: d.fmt(card.sym) }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function shareUrl(baseUrl, card) {
|
|
115
|
+
return `${String(baseUrl).replace(/\/+$/, '')}/r?${encodeCard(card)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function shareText(card, lang = 'en') {
|
|
119
|
+
if (lang === 'fr') {
|
|
120
|
+
const st = STAGES_FR[card.stage];
|
|
121
|
+
const ar = ARCHETYPES_FR[card.archetype];
|
|
122
|
+
return `Je viens de passer le Claude Dependency Test : ${card.score}/100, stade ${card.stage} (${st.name.toLowerCase()}).\nDiagnostic : ${ar.name} ${ARCHETYPES[card.archetype].emoji}\nEt toi, à quel point es-tu accro à Claude ?`;
|
|
123
|
+
}
|
|
124
|
+
const st = STAGES[card.stage];
|
|
125
|
+
const ar = ARCHETYPES[card.archetype];
|
|
126
|
+
return `Just got my Claude Dependency Test results: ${card.score}/100, Stage ${card.stage} (${st.name}).\nDiagnosis: ${ar.name} ${ar.emoji}\nHow addicted to Claude are you?`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One-click share links. `via` credits the author: X shows "via @…" and suggests following. */
|
|
130
|
+
export function shareIntents(url, text, via = {}) {
|
|
131
|
+
const e = encodeURIComponent;
|
|
132
|
+
const credit = (h) => (h ? `\nvia @${h}` : '');
|
|
133
|
+
const x = `https://x.com/intent/tweet?text=${e(text)}&url=${e(url)}${via.x ? `&via=${e(via.x)}&related=${e(via.x)}` : ''}`;
|
|
134
|
+
return {
|
|
135
|
+
x,
|
|
136
|
+
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${e(url)}`,
|
|
137
|
+
bluesky: `https://bsky.app/intent/compose?text=${e(`${text}\n${url}${credit(via.bluesky)}`)}`,
|
|
138
|
+
// Mastodon has no single share URL (every instance differs): Toot asks for your instance.
|
|
139
|
+
mastodon: `https://toot.kytta.dev/?text=${e(`${text}\n${url}${credit(via.mastodon)}`)}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** With a slug, the badge follows the published page and always shows the latest score. */
|
|
144
|
+
export function badgeUrl(baseUrl, card, slug) {
|
|
145
|
+
const base = String(baseUrl).replace(/\/+$/, '');
|
|
146
|
+
return slug ? `${base}/badge/${slug}` : `${base}/badge?s=${card.score}&st=${card.stage}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Usage stats: real numbers, fun framing.
|
|
151
|
+
|
|
152
|
+
// Built-in Claude Code tools that may appear in a published report. MCP tools are grouped
|
|
153
|
+
// under "mcp" (no server names), anything else under "other".
|
|
154
|
+
export const TOOL_NAMES = [
|
|
155
|
+
'Bash', 'Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Grep', 'Glob', 'Agent', 'WebFetch', 'WebSearch',
|
|
156
|
+
'TodoWrite', 'AskUserQuestion', 'ToolSearch', 'Skill', 'ExitPlanMode', 'Monitor', 'Workflow', 'mcp', 'other',
|
|
157
|
+
];
|
|
158
|
+
export function normalizeToolName(name) {
|
|
159
|
+
if (name.startsWith('mcp__')) return 'mcp';
|
|
160
|
+
if (name === 'Task') return 'Agent';
|
|
161
|
+
return TOOL_NAMES.includes(name) ? name : 'other';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Reference sizes for comparisons (word counts of the books; 1 token ≈ 0.75 English words).
|
|
165
|
+
export const WORDS_PER_TOKEN = 0.75;
|
|
166
|
+
export const WAR_AND_PEACE_WORDS = 587_287;
|
|
167
|
+
const HARRY_POTTER_SERIES_TOKENS = 1_084_170 / WORDS_PER_TOKEN;
|
|
168
|
+
|
|
169
|
+
export function compact(n, lang = 'en') {
|
|
170
|
+
const fr = lang === 'fr';
|
|
171
|
+
const d = (x, digits) => (fr ? x.toFixed(digits).replace('.', ',') : x.toFixed(digits));
|
|
172
|
+
if (n >= 1e9) return `${d(n / 1e9, n >= 1e10 ? 0 : 1)}${fr ? ' Md' : 'B'}`;
|
|
173
|
+
if (n >= 1e6) return `${d(n / 1e6, n >= 1e7 ? 0 : 1)}${fr ? ' M' : 'M'}`;
|
|
174
|
+
if (n >= 1e4) return `${Math.round(n / 1e3)}${fr ? ' k' : 'K'}`;
|
|
175
|
+
return Math.round(n).toLocaleString(fr ? 'fr-FR' : 'en-US');
|
|
176
|
+
}
|
|
177
|
+
export const formatMinuteOfDay = (m, lang = 'en') =>
|
|
178
|
+
lang === 'fr' ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${formatHour(Math.floor(m / 60)).replace(' ', `:${String(m % 60).padStart(2, '0')} `)}`;
|
|
179
|
+
const duration = (min) => (min >= 60 ? `${Math.floor(min / 60)} h ${String(Math.round(min % 60)).padStart(2, '0')} min` : `${Math.round(min)} min`);
|
|
180
|
+
const times = (n, lang = 'en') => (n >= 10 ? `${Math.round(n)}×` : `${lang === 'fr' ? n.toFixed(1).replace('.', ',') : n.toFixed(1)}×`);
|
|
181
|
+
|
|
182
|
+
/** Fun facts built only from real numbers. Returns [{ key, value, label, caption }]. */
|
|
183
|
+
export function usageFacts(u, lang = 'en') {
|
|
184
|
+
if (!u) return [];
|
|
185
|
+
const fr = lang === 'fr';
|
|
186
|
+
const c = (n) => compact(n, lang);
|
|
187
|
+
const t = u.toolCalls || {};
|
|
188
|
+
const f = [];
|
|
189
|
+
const add = (key, value, label, caption) => f.push({ key, value, label, caption });
|
|
190
|
+
if (u.outputTokens > 0) {
|
|
191
|
+
const words = u.outputTokens * WORDS_PER_TOKEN;
|
|
192
|
+
const x = times(words / WAR_AND_PEACE_WORDS, lang);
|
|
193
|
+
add('words', c(words), fr ? 'mots écrits par Claude' : 'words written by Claude', fr ? `Environ ${x} Guerre et Paix (réflexion comprise).` : `About ${x} War and Peace (thinking included).`);
|
|
194
|
+
}
|
|
195
|
+
if (u.totalTokens > 0) {
|
|
196
|
+
const x = c(u.totalTokens / HARRY_POTTER_SERIES_TOKENS);
|
|
197
|
+
add('tokens', c(u.totalTokens), fr ? 'tokens traités' : 'tokens processed', fr ? `Comme relire toute la saga Harry Potter ${x} fois.` : `Like re-reading the whole Harry Potter saga ${x} times.`);
|
|
198
|
+
}
|
|
199
|
+
if (t.Bash) add('bash', c(t.Bash), fr ? 'commandes shell lancées' : 'shell commands run', fr ? "Ton terminal n'a jamais autant travaillé." : 'Your terminal has never been this busy.');
|
|
200
|
+
const edits = (t.Edit || 0) + (t.MultiEdit || 0) + (t.Write || 0) + (t.NotebookEdit || 0);
|
|
201
|
+
if (t.Read || edits) add('files', `${c(t.Read || 0)} / ${c(edits)}`, fr ? 'lectures / modifications de fichiers' : 'file reads / edits', fr ? "On lit d'abord, on modifie ensuite. En général." : 'Reads first, edits later. Mostly.');
|
|
202
|
+
if (u.subagents) add('subagents', c(u.subagents), fr ? 'sous-agents engagés' : 'subagents hired', fr ? 'Claude délègue, comme un vrai manager.' : 'Claude delegates, like a real manager.');
|
|
203
|
+
if (u.longestTurnMinutes >= 1) add('longest-turn', duration(u.longestTurnMinutes), fr ? 'plus long travail de Claude en solo' : 'longest solo run by Claude', fr ? 'Sur une seule demande, sans toi.' : 'On a single request, without you.');
|
|
204
|
+
if (u.claudeHours >= 1) add('claude-hours', `${c(u.claudeHours)} h`, fr ? 'de Claude au travail pour toi' : 'of Claude working on your requests', fr ? 'Du prompt à la réponse finale de Claude, additionné.' : "Timed from each prompt to Claude's final answer.");
|
|
205
|
+
const one = u.interruptions === 1;
|
|
206
|
+
add('interruptions', c(u.interruptions),
|
|
207
|
+
fr ? `fois où tu as arrêté Claude avec Échap` : `time${one ? '' : 's'} you hit Esc on Claude`,
|
|
208
|
+
u.interruptions === 0 ? (fr ? 'Patience de saint.' : 'Saintly patience.') : u.interruptions > 30 ? (fr ? 'Brutal, mais efficace.' : 'Rude, but efficient.') : (fr ? 'Seulement quand il le fallait.' : 'Only when it mattered.'));
|
|
209
|
+
if (u.compactions) add('compactions', c(u.compactions), fr ? `débordement${u.compactions === 1 ? '' : 's'} de contexte` : `context overflow${u.compactions === 1 ? '' : 's'}`, fr ? 'Des conversations si longues que Claude a dû les résumer.' : 'Conversations so long Claude had to summarize them.');
|
|
210
|
+
if (u.projects) add('projects', c(u.projects), fr ? `projet${u.projects === 1 ? '' : 's'} en cours` : `project${u.projects === 1 ? '' : 's'} on the go`, u.projects > 10 ? (fr ? 'La concentration, c\'est surfait.' : 'Focus is overrated.') : (fr ? 'Un portefeuille raisonnable.' : 'A reasonable portfolio.'));
|
|
211
|
+
const web = (t.WebSearch || 0) + (t.WebFetch || 0);
|
|
212
|
+
if (web) add('web', c(web), fr ? 'recherches et pages web consultées' : 'web searches and page fetches', fr ? 'Claude cherche sur le web pour que tu n\'aies pas à le faire.' : "Claude googles so you don't have to.");
|
|
213
|
+
if (t.mcp) add('mcp', c(t.mcp), fr ? 'appels d\'outils MCP' : 'MCP tool calls', fr ? 'Branché sur tout.' : 'Plugged into everything.');
|
|
214
|
+
if (u.latestNightMinute != null) add('bedtime', formatMinuteOfDay(u.latestNightMinute, lang), fr ? 'dernier prompt de la journée' : 'latest prompt of the day', fr ? 'Les bonnes idées ont leurs propres horaires.' : 'Good ideas keep their own hours.');
|
|
215
|
+
if (u.earliestMorningMinute != null) add('early', formatMinuteOfDay(u.earliestMorningMinute, lang), fr ? 'premier prompt de la journée' : 'earliest start of the day', fr ? "Le premier prompt d'une longue journée." : 'The first prompt of a long day.');
|
|
216
|
+
return f;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The most spectacular numbers, ranked by a "wow" ratio (value ÷ a reference an ordinary user
|
|
221
|
+
* rarely reaches). Input: { usage, sample, extras: { currentStreak, longestSessionHours, busiestDay } }.
|
|
222
|
+
* Returns [{ key, label, value, hint, color, wow, line }], most spectacular first.
|
|
223
|
+
*/
|
|
224
|
+
export function headlines(r, limit = 5, lang = 'en') {
|
|
225
|
+
const u = r.usage || {};
|
|
226
|
+
const t = u.toolCalls || {};
|
|
227
|
+
const e = r.extras || {};
|
|
228
|
+
const books = ((u.outputTokens || 0) * WORDS_PER_TOKEN) / WAR_AND_PEACE_WORDS;
|
|
229
|
+
const dur = (min) => (min >= 60 ? `${Math.floor(min / 60)} H ${String(Math.round(min % 60)).padStart(2, '0')}` : `${Math.round(min)} MIN`);
|
|
230
|
+
const fr = lang === 'fr';
|
|
231
|
+
const c = (n) => compact(n, lang);
|
|
232
|
+
const busiest = e.busiestDay && e.busiestDay.day
|
|
233
|
+
? new Date(`${e.busiestDay.day}T12:00:00Z`).toLocaleDateString(fr ? 'fr-FR' : 'en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
|
234
|
+
: '';
|
|
235
|
+
const bookCount = books >= 10 ? Math.round(books) : fr ? books.toFixed(1).replace('.', ',') : books.toFixed(1);
|
|
236
|
+
const h = (x) => (fr ? `${c(x)} H` : `${c(x)} H`);
|
|
237
|
+
// label: what it is, in plain words · value: the number with its unit · hint: one line of context
|
|
238
|
+
const list = [
|
|
239
|
+
{ key: 'words', raw: books, ref: 3, label: fr ? 'MOTS ÉCRITS PAR CLAUDE' : 'WORDS WRITTEN BY CLAUDE', value: c((u.outputTokens || 0) * WORDS_PER_TOKEN), hint: fr ? `Environ ${bookCount} exemplaires de Guerre et Paix, réflexion comprise` : `About ${bookCount} copies of War and Peace, thinking included`, color: 'cyan' },
|
|
240
|
+
{ key: 'solo', raw: u.longestTurnMinutes || 0, ref: 90, label: fr ? 'PLUS LONG TRAVAIL SANS TOI' : 'LONGEST RUN WITHOUT YOU', value: dur(u.longestTurnMinutes || 0), hint: fr ? 'Claude seul, sur une seule demande' : 'Claude working alone on a single request', color: 'amber' },
|
|
241
|
+
{ key: 'subagents', raw: u.subagents || 0, ref: 40, label: fr ? 'SOUS-AGENTS LANCÉS' : 'SUBAGENTS LAUNCHED', value: c(u.subagents || 0), hint: fr ? 'Des agents assistants lancés par Claude pour répartir le travail' : 'Helper agents Claude started to split the work', color: 'amber' },
|
|
242
|
+
{ key: 'session', raw: e.longestSessionHours || 0, ref: 3, label: fr ? 'PLUS LONGUE SESSION' : 'LONGEST SESSION', value: `${fr ? (e.longestSessionHours || 0).toFixed(1).replace('.', ',') : (e.longestSessionHours || 0).toFixed(1)} H`, hint: fr ? 'Avec Claude, sans pause de 30 minutes' : 'With Claude, without a 30-minute break', color: 'magenta' },
|
|
243
|
+
{ key: 'streak', raw: e.currentStreak || 0, ref: 10, label: fr ? "JOURS D'AFFILÉE" : 'DAYS IN A ROW', value: `${e.currentStreak || 0}`, hint: fr ? 'Série en cours, au moins un prompt par jour' : 'Current streak, at least one prompt a day', color: 'amber' },
|
|
244
|
+
{ key: 'binge', raw: (e.busiestDay && e.busiestDay.prompts) || 0, ref: 120, label: fr ? 'PROMPTS EN UNE JOURNÉE' : 'PROMPTS IN ONE DAY', value: c((e.busiestDay && e.busiestDay.prompts) || 0), hint: busiest ? (fr ? `Ta journée la plus chargée, le ${busiest}` : `Your busiest day, ${busiest}`) : fr ? 'Ta journée la plus chargée' : 'Your busiest day', color: 'ph' },
|
|
245
|
+
{ key: 'bash', raw: t.Bash || 0, ref: 2500, label: fr ? 'COMMANDES TERMINAL' : 'TERMINAL COMMANDS', value: c(t.Bash || 0), hint: fr ? 'Commandes shell lancées par Claude pour toi' : 'Shell commands Claude ran for you', color: 'ph' },
|
|
246
|
+
{ key: 'claude-hours', raw: u.claudeHours || 0, ref: 40, label: fr ? 'HEURES DE CLAUDE AU TRAVAIL' : 'HOURS OF CLAUDE AT WORK', value: h(u.claudeHours || 0), hint: fr ? 'Du prompt à la réponse finale de Claude, additionné' : "From each prompt to Claude's final answer, added up", color: 'cyan' },
|
|
247
|
+
{ key: 'esc', raw: u.interruptions || 0, ref: 40, label: fr ? 'FOIS OÙ TU AS ARRÊTÉ CLAUDE' : 'TIMES YOU STOPPED CLAUDE', value: c(u.interruptions || 0), hint: fr ? 'Échap pressé pendant que Claude travaillait' : 'Esc pressed while Claude was working', color: 'magenta' },
|
|
248
|
+
{ key: 'tokens', raw: u.totalTokens || 0, ref: 2e9, label: fr ? 'TOKENS TRAITÉS' : 'TOKENS PROCESSED', value: c(u.totalTokens || 0), hint: fr ? 'Surtout du contexte relu par Claude depuis son cache' : 'Mostly context Claude re-read from its cache', color: 'cyan' },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
return list
|
|
253
|
+
.filter((h) => h.raw > 0)
|
|
254
|
+
.map(({ raw, ref, ...h }) => ({ ...h, wow: raw / ref, line: `${h.value.toLowerCase()} ${h.label.toLowerCase()}: ${h.hint.toLowerCase()}` }))
|
|
255
|
+
.sort((a, b) => b.wow - a.wow)
|
|
256
|
+
.slice(0, limit);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Tool usage sorted by count, for charts. */
|
|
260
|
+
export function topTools(u, limit = 8) {
|
|
261
|
+
return Object.entries((u && u.toolCalls) || {})
|
|
262
|
+
.filter(([, n]) => n > 0)
|
|
263
|
+
.sort((a, b) => b[1] - a[1])
|
|
264
|
+
.slice(0, limit);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Published report: the aggregate payload sent to the share site when you publish.
|
|
269
|
+
// Numbers and enum values only. No free text can be published, so a page on the share
|
|
270
|
+
// site can never contain prompts, paths, names or anything a user typed.
|
|
271
|
+
|
|
272
|
+
export const REPORT_VERSION = 1;
|
|
273
|
+
|
|
274
|
+
export const CRITERIA = {
|
|
275
|
+
hours: 'Active hours per active day',
|
|
276
|
+
streak: 'Longest streak',
|
|
277
|
+
presence: 'Presence',
|
|
278
|
+
intensity: 'Intensity',
|
|
279
|
+
marathon: 'Marathon',
|
|
280
|
+
delegation: 'Delegation',
|
|
281
|
+
output: 'Output',
|
|
282
|
+
limits: 'Usage limits hit',
|
|
283
|
+
opus: 'Opus share',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const pct = (v) => `${Math.round(v * 100)}%`;
|
|
287
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
288
|
+
export const METRICS = {
|
|
289
|
+
activeHoursPerActiveDay: { label: 'Active hours / active day', value: (v) => `${v.toFixed(1)} h`, ref: (a, b) => `${a}-${b} h` },
|
|
290
|
+
activeDaysShare: { label: 'Days active', value: pct, ref: (a, b) => `${pct(a)}-${pct(b)}` },
|
|
291
|
+
longestStreakDays: { label: 'Longest streak', value: (v) => plural(v, 'day'), ref: (a, b) => `${a}-${b} d` },
|
|
292
|
+
nightPromptShare: { label: 'Prompts 00:00-05:00', value: pct, ref: (a, b) => `${pct(a)}-${pct(b)}` },
|
|
293
|
+
promptsPerActiveDay: { label: 'Prompts / active day', value: (v) => Math.round(v).toLocaleString('en-US'), ref: (a, b) => `${a}-${b}` },
|
|
294
|
+
weekendActivityShare: { label: 'Weekend activity', value: pct, ref: (a, b) => `${pct(a)}-${pct(b)}` },
|
|
295
|
+
limitHitsPer30Days: { label: 'Usage limits hit / 30 d', value: (v) => v.toFixed(1), ref: (a, b) => `${a}-${b}` },
|
|
296
|
+
opusShare: { label: 'Opus share', value: pct, ref: (a, b) => `${pct(a)}-${pct(b)}` },
|
|
297
|
+
longestSessionHours: { label: 'Longest session', value: (v) => `${v.toFixed(1)} h`, ref: (a, b) => `${a}-${b} h` },
|
|
298
|
+
subagentsPerActiveDay: { label: 'Subagents / active day', value: (v) => v.toFixed(1), ref: (a, b) => `${a}-${b}` },
|
|
299
|
+
wordsPerActiveDay: { label: 'Claude words / day', value: (v) => compact(v), ref: (a, b) => `${compact(a)}-${compact(b)}` },
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const MODEL_FAMILIES = ['opus', 'fable', 'sonnet', 'haiku', 'other'];
|
|
303
|
+
export const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
304
|
+
export const WEEKDAYS_FR = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
|
|
305
|
+
export const weekdays = (lang = 'en') => (lang === 'fr' ? WEEKDAYS_FR : WEEKDAYS);
|
|
306
|
+
|
|
307
|
+
export const CRITERIA_FR = {
|
|
308
|
+
hours: 'Heures actives par jour actif',
|
|
309
|
+
streak: 'Plus longue série',
|
|
310
|
+
presence: 'Présence',
|
|
311
|
+
intensity: 'Intensité',
|
|
312
|
+
marathon: 'Marathon',
|
|
313
|
+
delegation: 'Délégation',
|
|
314
|
+
output: 'Production',
|
|
315
|
+
limits: "Limites d'usage atteintes",
|
|
316
|
+
opus: "Part d'Opus",
|
|
317
|
+
};
|
|
318
|
+
export const METRIC_LABELS_FR = {
|
|
319
|
+
activeHoursPerActiveDay: 'Heures actives / jour actif',
|
|
320
|
+
activeDaysShare: 'Jours actifs',
|
|
321
|
+
longestStreakDays: 'Plus longue série',
|
|
322
|
+
nightPromptShare: 'Prompts 0 h-5 h',
|
|
323
|
+
promptsPerActiveDay: 'Prompts / jour actif',
|
|
324
|
+
weekendActivityShare: 'Activité le week-end',
|
|
325
|
+
limitHitsPer30Days: 'Limites atteintes / 30 j',
|
|
326
|
+
opusShare: "Part d'Opus",
|
|
327
|
+
longestSessionHours: 'Plus longue session',
|
|
328
|
+
subagentsPerActiveDay: 'Sous-agents / jour actif',
|
|
329
|
+
wordsPerActiveDay: 'Mots de Claude / jour',
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export function formatHour(h, lang = 'en') {
|
|
333
|
+
if (lang === 'fr') return `${h} h`;
|
|
334
|
+
const h12 = h % 12 === 0 ? 12 : h % 12;
|
|
335
|
+
return `${h12} ${h < 12 ? 'AM' : 'PM'}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Flag printed next to a criterion, like a lab report. */
|
|
339
|
+
export function severityFlag(ratio) {
|
|
340
|
+
if (ratio >= 0.75) return 'HIGH';
|
|
341
|
+
if (ratio >= 0.4) return 'ELEV';
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const MAX_COUNT = 10_000_000;
|
|
346
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
347
|
+
class Invalid extends Error {}
|
|
348
|
+
const fail = (what) => { throw new Invalid(what); };
|
|
349
|
+
const isObj = (o) => o !== null && typeof o === 'object' && !Array.isArray(o);
|
|
350
|
+
const int = (v, lo, hi, what) => (Number.isInteger(v) && v >= lo && v <= hi ? v : fail(what));
|
|
351
|
+
const num = (v, lo, hi, what) => (typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? Math.round(v * 1000) / 1000 : fail(what));
|
|
352
|
+
const day = (v, what) => (typeof v === 'string' && DAY_RE.test(v) && !Number.isNaN(Date.parse(`${v}T00:00:00Z`)) ? v : fail(what));
|
|
353
|
+
const oneOf = (v, set, what) => (typeof v === 'string' && Object.prototype.hasOwnProperty.call(set, v) ? v : fail(what));
|
|
354
|
+
const ints = (a, len, what) => (Array.isArray(a) && a.length === len ? a.map((v) => int(v, 0, MAX_COUNT, what)) : fail(what));
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Validates an untrusted report and rebuilds it from known fields only.
|
|
358
|
+
* Returns { report } or { error }. Unknown keys are dropped; any invalid value rejects the whole report.
|
|
359
|
+
*/
|
|
360
|
+
export function sanitizeReport(input) {
|
|
361
|
+
try {
|
|
362
|
+
if (!isObj(input) || input.v !== REPORT_VERSION) fail('v');
|
|
363
|
+
const score = int(input.score, 0, 100, 'score');
|
|
364
|
+
const stage = int(input.stage, 1, 5, 'stage');
|
|
365
|
+
const archetype = oneOf(input.archetype, ARCHETYPES, 'archetype');
|
|
366
|
+
const w = isObj(input.window) ? input.window : fail('window');
|
|
367
|
+
const window = { days: int(w.days, 1, 3650, 'window.days'), startDay: day(w.startDay, 'window.startDay'), endDay: day(w.endDay, 'window.endDay') };
|
|
368
|
+
const s = isObj(input.sample) ? input.sample : fail('sample');
|
|
369
|
+
const sample = {
|
|
370
|
+
prompts: int(s.prompts, 0, MAX_COUNT, 'sample.prompts'),
|
|
371
|
+
sessions: int(s.sessions, 0, MAX_COUNT, 'sample.sessions'),
|
|
372
|
+
activeDays: int(s.activeDays, 0, window.days, 'sample.activeDays'),
|
|
373
|
+
activeHours: num(s.activeHours, 0, window.days * 24, 'sample.activeHours'),
|
|
374
|
+
};
|
|
375
|
+
if (!Array.isArray(input.criteria) || input.criteria.length > Object.keys(CRITERIA).length) fail('criteria');
|
|
376
|
+
const seen = new Set();
|
|
377
|
+
const criteria = input.criteria.map((c) => {
|
|
378
|
+
if (!isObj(c)) fail('criteria[]');
|
|
379
|
+
const key = oneOf(c.key, CRITERIA, 'criteria.key');
|
|
380
|
+
if (seen.has(key)) fail('criteria.key');
|
|
381
|
+
seen.add(key);
|
|
382
|
+
const points = int(c.points, 0, 100, 'criteria.points');
|
|
383
|
+
if (c.earned === null) return { key, points, earned: null, parts: [] };
|
|
384
|
+
const earned = num(c.earned, 0, points, 'criteria.earned');
|
|
385
|
+
if (!Array.isArray(c.parts) || c.parts.length < 1 || c.parts.length > 2) fail('criteria.parts');
|
|
386
|
+
const parts = c.parts.map((p) => {
|
|
387
|
+
if (!isObj(p)) fail('parts[]');
|
|
388
|
+
return {
|
|
389
|
+
metric: oneOf(p.metric, METRICS, 'parts.metric'),
|
|
390
|
+
value: num(p.value, 0, MAX_COUNT, 'parts.value'),
|
|
391
|
+
from: num(p.from, 0, MAX_COUNT, 'parts.from'),
|
|
392
|
+
to: num(p.to, 0, MAX_COUNT, 'parts.to'),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
return { key, points, earned, parts };
|
|
396
|
+
});
|
|
397
|
+
const e = isObj(input.extras) ? input.extras : fail('extras');
|
|
398
|
+
const bd = isObj(e.busiestDay) ? e.busiestDay : fail('extras.busiestDay');
|
|
399
|
+
const extras = {
|
|
400
|
+
peakHour: int(e.peakHour, 0, 23, 'extras.peakHour'),
|
|
401
|
+
peakWeekday: int(e.peakWeekday, 0, 6, 'extras.peakWeekday'),
|
|
402
|
+
currentStreak: int(e.currentStreak, 0, window.days, 'extras.currentStreak'),
|
|
403
|
+
longestSessionHours: num(e.longestSessionHours, 0, window.days * 24, 'extras.longestSessionHours'),
|
|
404
|
+
busiestDay: { day: day(bd.day, 'busiestDay.day'), prompts: int(bd.prompts, 0, MAX_COUNT, 'busiestDay.prompts') },
|
|
405
|
+
};
|
|
406
|
+
if (!isObj(input.modelMix)) fail('modelMix');
|
|
407
|
+
const modelMix = {};
|
|
408
|
+
for (const [k, v] of Object.entries(input.modelMix)) {
|
|
409
|
+
if (!MODEL_FAMILIES.includes(k)) fail('modelMix.key');
|
|
410
|
+
modelMix[k] = num(v, 0, 1, 'modelMix.value');
|
|
411
|
+
}
|
|
412
|
+
const us = isObj(input.usage) ? input.usage : fail('usage');
|
|
413
|
+
if (!isObj(us.toolCalls)) fail('usage.toolCalls');
|
|
414
|
+
const toolCalls = {};
|
|
415
|
+
for (const [k, v] of Object.entries(us.toolCalls)) {
|
|
416
|
+
if (!TOOL_NAMES.includes(k)) fail('usage.toolCalls.key');
|
|
417
|
+
toolCalls[k] = int(v, 0, MAX_COUNT, 'usage.toolCalls.value');
|
|
418
|
+
}
|
|
419
|
+
const minuteOrNull = (v, what) => (v === null ? null : int(v, 0, 24 * 60 - 1, what));
|
|
420
|
+
const usage = {
|
|
421
|
+
outputTokens: int(us.outputTokens, 0, 1e13, 'usage.outputTokens'),
|
|
422
|
+
totalTokens: int(us.totalTokens, 0, 1e14, 'usage.totalTokens'),
|
|
423
|
+
toolCalls,
|
|
424
|
+
subagents: int(us.subagents, 0, MAX_COUNT, 'usage.subagents'),
|
|
425
|
+
compactions: int(us.compactions, 0, MAX_COUNT, 'usage.compactions'),
|
|
426
|
+
interruptions: int(us.interruptions, 0, MAX_COUNT, 'usage.interruptions'),
|
|
427
|
+
longestTurnMinutes: num(us.longestTurnMinutes, 0, window.days * 24 * 60, 'usage.longestTurnMinutes'),
|
|
428
|
+
claudeHours: num(us.claudeHours, 0, window.days * 24 * 20, 'usage.claudeHours'),
|
|
429
|
+
projects: int(us.projects, 0, 100_000, 'usage.projects'),
|
|
430
|
+
latestNightMinute: minuteOrNull(us.latestNightMinute, 'usage.latestNightMinute'),
|
|
431
|
+
earliestMorningMinute: minuteOrNull(us.earliestMorningMinute, 'usage.earliestMorningMinute'),
|
|
432
|
+
};
|
|
433
|
+
const report = {
|
|
434
|
+
v: REPORT_VERSION,
|
|
435
|
+
score,
|
|
436
|
+
stage,
|
|
437
|
+
archetype,
|
|
438
|
+
window,
|
|
439
|
+
sample,
|
|
440
|
+
criteria,
|
|
441
|
+
extras,
|
|
442
|
+
modelMix,
|
|
443
|
+
usage,
|
|
444
|
+
hours: ints(input.hours, 24, 'hours'),
|
|
445
|
+
weekdays: ints(input.weekdays, 7, 'weekdays'),
|
|
446
|
+
daily: ints(input.daily, window.days, 'daily'),
|
|
447
|
+
};
|
|
448
|
+
// The card must describe the same result, and pass the same strict parser as share URLs.
|
|
449
|
+
if (!isObj(input.card)) fail('card');
|
|
450
|
+
const card = decodeCard(encodeCard({ ...input.card, sym: isObj(input.card.sym) ? input.card.sym : {} }));
|
|
451
|
+
if (!card || card.score !== score || card.stage !== stage || card.archetype !== archetype) fail('card');
|
|
452
|
+
report.card = card;
|
|
453
|
+
return { report };
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if (err instanceof Invalid) return { error: `invalid field: ${err.message}` };
|
|
456
|
+
return { error: 'invalid report' };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Canvas renderer (browser only): the card is a patient monitor screen, 1200×630.
|
|
462
|
+
// Needs the VT323 and IBM Plex Mono fonts loaded (see ui.js / report template).
|
|
463
|
+
|
|
464
|
+
export const MONITOR = {
|
|
465
|
+
screen: '#030A06', bezel: '#0F3A22', line: '#1A5C37', grid: '#0B2416',
|
|
466
|
+
ph: '#3BFF8C', dim: '#1F9A57', faint: '#2E7550', ink: '#B9F7D2',
|
|
467
|
+
amber: '#FFB020', cyan: '#44E0FF', red: '#FF4D4D', magenta: '#FF5AD1',
|
|
468
|
+
stages: { 1: '#3BFF8C', 2: '#B8F23B', 3: '#FFD21F', 4: '#FFB020', 5: '#FF4D4D' },
|
|
469
|
+
};
|
|
470
|
+
// Kept for callers of the previous API.
|
|
471
|
+
export const THEMES = { light: MONITOR, dark: MONITOR };
|
|
472
|
+
|
|
473
|
+
const DISPLAY = 'VT323, ui-monospace, monospace';
|
|
474
|
+
const MONO = '"IBM Plex Mono", ui-monospace, Menlo, monospace';
|
|
475
|
+
|
|
476
|
+
// Short readouts for the bottom row of the monitor.
|
|
477
|
+
const READOUTS = [
|
|
478
|
+
{ key: 'sk', label: 'STREAK', fmt: (v) => `${v} D`, color: 'amber' },
|
|
479
|
+
{ key: 'h', label: 'HOURS/DAY', fmt: (v) => `${v.toFixed(1)} H`, color: 'cyan' },
|
|
480
|
+
{ key: 'p', label: 'PROMPTS/DAY', fmt: (v) => `${v}`, color: 'ph' },
|
|
481
|
+
{ key: 'ad', label: 'DAYS ACTIVE', fmt: (v) => `${v}%`, color: 'ph' },
|
|
482
|
+
{ key: 'o', label: 'OPUS', fmt: (v) => `${v}%`, color: 'magenta' },
|
|
483
|
+
{ key: 'w', label: 'WEEKEND', fmt: (v) => `${v}%`, color: 'cyan' },
|
|
484
|
+
{ key: 'n', label: '0-5 AM', fmt: (v) => `${v}%`, color: 'cyan' },
|
|
485
|
+
{ key: 'l', label: 'LIMITS HIT', fmt: (v) => `${v}×`, color: 'amber' },
|
|
486
|
+
{ key: 'ls', label: 'LONGEST SESSION', fmt: (v) => `${v.toFixed(1)} H`, color: 'magenta' },
|
|
487
|
+
{ key: 'sa', label: 'SUBAGENTS', fmt: (v) => `${v}`, color: 'amber' },
|
|
488
|
+
{ key: 'wd', label: 'WORDS/DAY', fmt: (v) => compact(v), color: 'cyan' },
|
|
489
|
+
];
|
|
490
|
+
export const readouts = (card) =>
|
|
491
|
+
card.headlines && card.headlines.length
|
|
492
|
+
? card.headlines
|
|
493
|
+
: READOUTS.filter((r) => card.sym[r.key] != null).map((r) => ({ ...r, value: r.fmt(card.sym[r.key]) }));
|
|
494
|
+
|
|
495
|
+
/** ECG points (in a w×h box) from daily prompt counts: one heartbeat per day, height = prompts. */
|
|
496
|
+
export function ecgPoints(daily, w, h) {
|
|
497
|
+
const n = daily.length;
|
|
498
|
+
const max = Math.max(1, ...daily);
|
|
499
|
+
const step = w / n;
|
|
500
|
+
const base = h * 0.72;
|
|
501
|
+
const pts = [];
|
|
502
|
+
daily.forEach((v, i) => {
|
|
503
|
+
const x = i * step;
|
|
504
|
+
const a = (v / max) * h * 0.66;
|
|
505
|
+
if (v === 0) {
|
|
506
|
+
pts.push([x, base], [x + step, base]);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
pts.push([x, base], [x + step * 0.3, base], [x + step * 0.36, base - a * 0.12], [x + step * 0.42, base],
|
|
510
|
+
[x + step * 0.48, base - a], [x + step * 0.54, base + a * 0.28], [x + step * 0.6, base], [x + step, base]);
|
|
511
|
+
});
|
|
512
|
+
return pts;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** A generic rhythm when no daily series is available (share links carry no daily data). */
|
|
516
|
+
function syntheticDaily(score) {
|
|
517
|
+
return Array.from({ length: 14 }, (_, i) => Math.round(30 + score * (0.5 + 0.5 * Math.abs(Math.sin(i * 1.7)))));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function wrap(ctx, text, maxWidth) {
|
|
521
|
+
const words = text.split(' ');
|
|
522
|
+
const lines = [];
|
|
523
|
+
let line = '';
|
|
524
|
+
for (const w of words) {
|
|
525
|
+
const test = line ? `${line} ${w}` : w;
|
|
526
|
+
if (ctx.measureText(test).width > maxWidth && line) {
|
|
527
|
+
lines.push(line);
|
|
528
|
+
line = w;
|
|
529
|
+
} else line = test;
|
|
530
|
+
}
|
|
531
|
+
if (line) lines.push(line);
|
|
532
|
+
return lines;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
536
|
+
ctx.beginPath();
|
|
537
|
+
ctx.moveTo(x + r, y);
|
|
538
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
539
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
540
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
541
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
542
|
+
ctx.closePath();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function glowText(ctx, text, x, y, color, blur = 14) {
|
|
546
|
+
ctx.save();
|
|
547
|
+
ctx.fillStyle = color;
|
|
548
|
+
ctx.shadowColor = color;
|
|
549
|
+
ctx.shadowBlur = blur;
|
|
550
|
+
ctx.fillText(text, x, y);
|
|
551
|
+
ctx.restore();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function drawCard(ctx, card, { siteLabel = '', credit = '' } = {}) {
|
|
555
|
+
const c = MONITOR;
|
|
556
|
+
const W = CARD_WIDTH;
|
|
557
|
+
const H = CARD_HEIGHT;
|
|
558
|
+
const stage = STAGES[card.stage];
|
|
559
|
+
const arch = ARCHETYPES[card.archetype];
|
|
560
|
+
ctx.save();
|
|
561
|
+
ctx.textBaseline = 'alphabetic';
|
|
562
|
+
|
|
563
|
+
// Bezel and screen
|
|
564
|
+
ctx.fillStyle = '#010302';
|
|
565
|
+
ctx.fillRect(0, 0, W, H);
|
|
566
|
+
ctx.fillStyle = c.screen;
|
|
567
|
+
roundRect(ctx, 14, 14, W - 28, H - 28, 26);
|
|
568
|
+
ctx.fill();
|
|
569
|
+
ctx.strokeStyle = c.line;
|
|
570
|
+
ctx.lineWidth = 2;
|
|
571
|
+
ctx.stroke();
|
|
572
|
+
|
|
573
|
+
// Top bar
|
|
574
|
+
ctx.textAlign = 'left';
|
|
575
|
+
ctx.font = `400 34px ${DISPLAY}`;
|
|
576
|
+
glowText(ctx, '● CLAUDE DEPENDENCY MONITOR', 48, 66, c.ph, 12);
|
|
577
|
+
ctx.fillStyle = c.red;
|
|
578
|
+
ctx.fillText('●', 48, 66);
|
|
579
|
+
ctx.font = `400 22px ${DISPLAY}`;
|
|
580
|
+
ctx.fillStyle = c.dim;
|
|
581
|
+
ctx.fillText(`BED 04 · ANONYMOUS DEVELOPER${card.days ? ` · OBSERVED ${card.days} DAYS` : ''}`, 48, 94);
|
|
582
|
+
// Alarm
|
|
583
|
+
ctx.font = `400 28px ${DISPLAY}`;
|
|
584
|
+
const alarm = arch.alarm || '● STABLE';
|
|
585
|
+
const aw = ctx.measureText(alarm).width + 28;
|
|
586
|
+
ctx.fillStyle = alarm.startsWith('▲') ? c.amber : c.ph;
|
|
587
|
+
ctx.fillRect(W - 48 - aw, 44, aw, 34);
|
|
588
|
+
ctx.fillStyle = '#000';
|
|
589
|
+
ctx.fillText(alarm, W - 48 - aw + 14, 70);
|
|
590
|
+
|
|
591
|
+
ctx.strokeStyle = c.bezel;
|
|
592
|
+
ctx.lineWidth = 1;
|
|
593
|
+
ctx.beginPath();
|
|
594
|
+
ctx.moveTo(48, 112);
|
|
595
|
+
ctx.lineTo(W - 48, 112);
|
|
596
|
+
ctx.stroke();
|
|
597
|
+
|
|
598
|
+
// ECG panel
|
|
599
|
+
const ex = 48;
|
|
600
|
+
const ey = 128;
|
|
601
|
+
const ew = 720;
|
|
602
|
+
const eh = 220;
|
|
603
|
+
ctx.strokeStyle = c.grid;
|
|
604
|
+
for (let gx = ex; gx <= ex + ew; gx += 30) { ctx.beginPath(); ctx.moveTo(gx, ey); ctx.lineTo(gx, ey + eh); ctx.stroke(); }
|
|
605
|
+
for (let gy = ey; gy <= ey + eh; gy += 30) { ctx.beginPath(); ctx.moveTo(ex, gy); ctx.lineTo(ex + ew, gy); ctx.stroke(); }
|
|
606
|
+
ctx.font = `400 22px ${DISPLAY}`;
|
|
607
|
+
ctx.fillStyle = c.dim;
|
|
608
|
+
ctx.fillText(card.ecg ? `II · PROMPTS TO CLAUDE PER DAY · ${card.ecg.length} D` : 'II · DEPENDENCY RHYTHM', ex + 8, ey + 24);
|
|
609
|
+
const pts = ecgPoints(card.ecg || syntheticDaily(card.score), ew, eh);
|
|
610
|
+
ctx.save();
|
|
611
|
+
ctx.strokeStyle = c.ph;
|
|
612
|
+
ctx.lineWidth = 3;
|
|
613
|
+
ctx.lineJoin = 'round';
|
|
614
|
+
ctx.shadowColor = c.ph;
|
|
615
|
+
ctx.shadowBlur = 12;
|
|
616
|
+
ctx.beginPath();
|
|
617
|
+
pts.forEach(([x, y], i) => (i ? ctx.lineTo(ex + x, ey + y) : ctx.moveTo(ex + x, ey + y)));
|
|
618
|
+
ctx.stroke();
|
|
619
|
+
ctx.restore();
|
|
620
|
+
|
|
621
|
+
// Diagnosis
|
|
622
|
+
ctx.font = `400 26px ${DISPLAY}`;
|
|
623
|
+
ctx.fillStyle = c.amber;
|
|
624
|
+
ctx.fillText(`DX · ${arch.code}`, 48, 392);
|
|
625
|
+
let size = 62;
|
|
626
|
+
do ctx.font = `400 ${size}px ${DISPLAY}`; while (ctx.measureText(arch.name.toUpperCase()).width > 720 && --size > 34);
|
|
627
|
+
glowText(ctx, arch.name.toUpperCase(), 48, 440, c.ph, 14);
|
|
628
|
+
ctx.font = `400 17px ${MONO}`;
|
|
629
|
+
ctx.fillStyle = c.ink;
|
|
630
|
+
wrap(ctx, arch.tagline, 720).slice(0, 2).forEach((l, i) => ctx.fillText(l, 48, 472 + i * 22));
|
|
631
|
+
|
|
632
|
+
// Score column
|
|
633
|
+
ctx.strokeStyle = c.bezel;
|
|
634
|
+
ctx.beginPath();
|
|
635
|
+
ctx.moveTo(800, 128);
|
|
636
|
+
ctx.lineTo(800, 500);
|
|
637
|
+
ctx.stroke();
|
|
638
|
+
ctx.font = `400 26px ${DISPLAY}`;
|
|
639
|
+
ctx.fillStyle = c.dim;
|
|
640
|
+
ctx.fillText('CLAUDE DEPENDENCY', 824, 152);
|
|
641
|
+
ctx.textAlign = 'right';
|
|
642
|
+
ctx.fillText('/100', W - 48, 152);
|
|
643
|
+
ctx.font = `400 270px ${DISPLAY}`;
|
|
644
|
+
glowText(ctx, String(card.score), W - 48, 380, '#EFFFF5', 26);
|
|
645
|
+
ctx.font = `400 34px ${DISPLAY}`;
|
|
646
|
+
glowText(ctx, `STAGE ${card.stage} · ${stage.name.toUpperCase()}`, W - 48, 428, c.stages[card.stage], 10);
|
|
647
|
+
ctx.textAlign = 'left';
|
|
648
|
+
|
|
649
|
+
// Readouts row
|
|
650
|
+
const cells = readouts(card).slice(0, 4);
|
|
651
|
+
const cw = (W - 96) / 4;
|
|
652
|
+
ctx.strokeStyle = c.bezel;
|
|
653
|
+
ctx.beginPath();
|
|
654
|
+
ctx.moveTo(48, 508);
|
|
655
|
+
ctx.lineTo(W - 48, 508);
|
|
656
|
+
ctx.stroke();
|
|
657
|
+
cells.forEach((r, i) => {
|
|
658
|
+
const x = 48 + i * cw;
|
|
659
|
+
ctx.font = `400 20px ${DISPLAY}`;
|
|
660
|
+
ctx.fillStyle = c.dim;
|
|
661
|
+
ctx.fillText(r.label, x, 534);
|
|
662
|
+
ctx.font = `400 40px ${DISPLAY}`;
|
|
663
|
+
glowText(ctx, r.value, x, 568, c[r.color], 8);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Footer
|
|
667
|
+
ctx.font = `400 20px ${DISPLAY}`;
|
|
668
|
+
ctx.fillStyle = c.dim;
|
|
669
|
+
ctx.fillText(`HOW ADDICTED TO CLAUDE ARE YOU?${credit ? ` · BY ${credit.toUpperCase()}` : ''}`, 48, 600);
|
|
670
|
+
ctx.textAlign = 'right';
|
|
671
|
+
ctx.fillStyle = c.faint;
|
|
672
|
+
ctx.fillText(`NOT AN ACTUAL MEDICAL DIAGNOSIS${siteLabel ? ` · ${siteLabel.toUpperCase()}` : ''}`, W - 48, 600);
|
|
673
|
+
ctx.textAlign = 'left';
|
|
674
|
+
|
|
675
|
+
// Scanlines + vignette
|
|
676
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
|
|
677
|
+
for (let y = 16; y < H - 16; y += 3) ctx.fillRect(16, y, W - 32, 1);
|
|
678
|
+
const g = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, W * 0.7);
|
|
679
|
+
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
680
|
+
g.addColorStop(1, 'rgba(0,0,0,0.45)');
|
|
681
|
+
ctx.fillStyle = g;
|
|
682
|
+
roundRect(ctx, 14, 14, W - 28, H - 28, 26);
|
|
683
|
+
ctx.fill();
|
|
684
|
+
ctx.restore();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Renders into a canvas at `scale` × 1200×630 (use scale 1 for the exported PNG). */
|
|
688
|
+
export function renderCardToCanvas(canvas, card, opts = {}) {
|
|
689
|
+
const scale = opts.scale || 1;
|
|
690
|
+
canvas.width = CARD_WIDTH * scale;
|
|
691
|
+
canvas.height = CARD_HEIGHT * scale;
|
|
692
|
+
const ctx = canvas.getContext('2d');
|
|
693
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
694
|
+
drawCard(ctx, card, opts);
|
|
695
|
+
return canvas;
|
|
696
|
+
}
|