@drakulavich/zapara 0.1.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/src/card.ts ADDED
@@ -0,0 +1,162 @@
1
+ // Card data: which of four characters a window is, the sentence behind it, the
2
+ // peak hour, the load spectrum and three highlights, every value already
3
+ // formatted for the page. Pure: Day[] in, CardData out. Nothing here knows a
4
+ // date, a path or a file, so nothing here can leak one.
5
+ import { formatCount, formatTokens, plural } from "./format.ts";
6
+ import { NORMS, WEIGHTS } from "./score.ts";
7
+ import type { Day, HourBucket, Level, Score } from "./types.ts";
8
+
9
+ export type Character = "conductor" | "supervisor" | "marathoner" | "nightOwl";
10
+ export type Segment = { text: string; strong: boolean };
11
+ export type HighlightKey = "peakSessions" | "contextSwitches" | "longestStreak" | "reportsRead" | "tokensRead" | "interrupts" | "lateShare";
12
+ export type Highlight = { key: HighlightKey; value: string; caption: string };
13
+ export type Spectrum = { calm: number; warming: number; heating: number; fried: number };
14
+ export type CardData = {
15
+ days: number;
16
+ character: Character;
17
+ name: string;
18
+ sentence: Segment[];
19
+ motto: string;
20
+ shares: Record<Character, number>;
21
+ peak: { index: number; level: Level };
22
+ spectrum: Spectrum;
23
+ highlights: Highlight[];
24
+ };
25
+
26
+ // Tie order: the first of equal shares wins.
27
+ const CHARACTERS: readonly Character[] = ["conductor", "supervisor", "marathoner", "nightOwl"];
28
+ const NAMES: Record<Character, string> = {
29
+ conductor: "The Conductor", supervisor: "The Supervisor", marathoner: "The Marathoner", nightOwl: "The Night Owl",
30
+ };
31
+ const MOTTOS: Record<Character, string> = {
32
+ conductor: "You run agents like an orchestra.",
33
+ supervisor: "Nothing ships without your eyes on it.",
34
+ marathoner: "You do not stop while it compiles.",
35
+ nightOwl: "The best commits happen after midnight.",
36
+ };
37
+ // Ranking norms for the third highlight: a value over its norm says how remarkable
38
+ // it is next to the others. Sums are per active hour. These rank a picture and
39
+ // never touch the index; the index's own norms stay in score.ts.
40
+ const CARD_NORMS = { reportsPerHour: 12, tokensPerHour: 65_000, interruptsPerHour: 3, latePercent: 25 } as const;
41
+ const OWNED: Record<Character, [HighlightKey, HighlightKey]> = {
42
+ conductor: ["peakSessions", "contextSwitches"],
43
+ supervisor: ["reportsRead", "tokensRead"],
44
+ marathoner: ["longestStreak", "interrupts"],
45
+ nightOwl: ["lateShare", "longestStreak"],
46
+ };
47
+ const POOL: readonly HighlightKey[] = ["peakSessions", "contextSwitches", "longestStreak", "reportsRead", "tokensRead", "interrupts", "lateShare"];
48
+ const CAPTIONS: Record<HighlightKey, string> = {
49
+ peakSessions: "sessions at once",
50
+ contextSwitches: "switches in one hour",
51
+ longestStreak: "longest streak",
52
+ reportsRead: "agent reports read",
53
+ tokensRead: "tokens of output read",
54
+ interrupts: "times you stopped Claude",
55
+ lateShare: "of hours after midnight",
56
+ };
57
+
58
+ function formatStreak(min: number): string {
59
+ if (min < 60) return `${min}m`;
60
+ const h = Math.floor(min / 60);
61
+ if (h < 10) return `${h}h${String(min % 60).padStart(2, "0")}m`;
62
+ if (h < 1000) return `${h}h`;
63
+ return "999h+";
64
+ }
65
+ const percent = (part: number, whole: number): number => Math.round((100 * part) / whole);
66
+
67
+ // Whole percents that sum to 100: floors first, then one more to the largest
68
+ // remainders, ties resolved in the given order.
69
+ function spectrumOf(counts: [number, number, number, number], total: number): Spectrum {
70
+ const raw = counts.map((c) => (100 * c) / total);
71
+ const floors = raw.map((r) => Math.floor(r)) as [number, number, number, number];
72
+ let left = 100 - floors.reduce((a, b) => a + b, 0);
73
+ const byRemainder = raw.map((r, i) => ({ i, rem: r - Math.floor(r) })).sort((a, b) => b.rem - a.rem || a.i - b.i);
74
+ for (const { i } of byRemainder) {
75
+ if (left === 0) break;
76
+ floors[i]! += 1;
77
+ left -= 1;
78
+ }
79
+ const [calm, warming, heating, fried] = floors;
80
+ return { calm, warming, heating, fried };
81
+ }
82
+
83
+ const strong = (text: string): Segment => ({ text, strong: true });
84
+ const plain = (text: string): Segment => ({ text, strong: false });
85
+ export const sentenceText = (segments: Segment[]): string => segments.map((s) => s.text).join("");
86
+
87
+ type Active = HourBucket & { score: Score };
88
+
89
+ export function cardData(days: Day[], w: { days: number }): CardData | null {
90
+ const active = days.flatMap((d) => d.buckets).filter((b): b is Active => b.score !== null);
91
+ const n = active.length;
92
+ if (n === 0) return null;
93
+ const sum = (f: (b: Active) => number): number => active.reduce((a, b) => a + f(b), 0);
94
+ const max = (f: (b: Active) => number): number => active.reduce((a, b) => Math.max(a, f(b)), 0);
95
+ const count = (level: Level): number => active.filter((b) => b.score.level === level).length;
96
+
97
+ // Each share is the fraction of that character's maximum possible points the
98
+ // window collected, so a 10-point component competes fairly with a 40-point one.
99
+ const shares: Record<Character, number> = {
100
+ conductor: sum((b) => b.score.parts.parallel + b.score.parts.pace) / ((WEIGHTS.parallel + WEIGHTS.pace) * n),
101
+ supervisor: sum((b) => b.score.parts.supervision + b.score.parts.reading) / ((WEIGHTS.supervision + WEIGHTS.reading) * n),
102
+ marathoner: sum((b) => b.score.parts.streak) / (WEIGHTS.streak * n),
103
+ nightOwl: sum((b) => b.score.parts.late) / (WEIGHTS.late * n),
104
+ };
105
+ // Strict > keeps the earlier of equal shares: CHARACTERS is the tie order.
106
+ const character = CHARACTERS.reduce((best, c) => (shares[c] > shares[best] ? c : best));
107
+
108
+ const maxSessions = max((b) => b.sessions);
109
+ const maxSwitches = max((b) => b.contextSwitches);
110
+ const maxStreak = max((b) => b.streakMin);
111
+ const reports = sum((b) => b.reports);
112
+ const tokens = sum((b) => b.outputTokens);
113
+ const interrupts = sum((b) => b.interrupts);
114
+ const late = percent(active.filter((b) => b.lateNight).length, n);
115
+ const calm = percent(count("Calm"), n);
116
+
117
+ const sentences: Record<Character, Segment[]> = {
118
+ conductor: [strong(plural(maxSessions, "session")), plain(" at once, "), strong(plural(maxSwitches, "context switch", "context switches")), plain(" in one hour.")],
119
+ supervisor: [strong(plural(reports, "agent report")), plain(" and "), strong(plural(tokens, "token", "tokens", formatTokens)), plain(" of output read.")],
120
+ marathoner: [plain("Longest streak "), strong(formatStreak(maxStreak)), plain(" without a break, "), strong(`${calm}%`), plain(" of your hours calm.")],
121
+ nightOwl: [strong(`${late}%`), plain(" of your hours "), strong("after midnight"), plain(".")],
122
+ };
123
+
124
+ const peakBucket = active.reduce((a, b) => (b.score.index > a.score.index ? b : a));
125
+ const spectrum = spectrumOf([count("Calm"), count("Warming"), count("Heating"), count("Fried")], n);
126
+
127
+ const values: Record<HighlightKey, string> = {
128
+ peakSessions: formatCount(maxSessions),
129
+ contextSwitches: formatCount(maxSwitches),
130
+ longestStreak: formatStreak(maxStreak),
131
+ reportsRead: formatCount(reports),
132
+ tokensRead: formatTokens(tokens),
133
+ interrupts: formatCount(interrupts),
134
+ lateShare: `${late}%`,
135
+ };
136
+ const norms: Record<HighlightKey, number> = {
137
+ peakSessions: (maxSessions - 1) / NORMS.parallelSpan,
138
+ contextSwitches: maxSwitches / NORMS.supervisionPerHour,
139
+ longestStreak: maxStreak / NORMS.streakMin,
140
+ reportsRead: reports / (CARD_NORMS.reportsPerHour * n),
141
+ tokensRead: tokens / (CARD_NORMS.tokensPerHour * n),
142
+ interrupts: interrupts / (CARD_NORMS.interruptsPerHour * n),
143
+ lateShare: late / CARD_NORMS.latePercent,
144
+ };
145
+ const owned = OWNED[character];
146
+ // A late-night number on anyone but the Night Owl is the kind of thing they would hide.
147
+ const rest = POOL.filter((k) => !owned.includes(k) && (k !== "lateShare" || character === "nightOwl"));
148
+ const third = rest.reduce((best, k) => (norms[k] > norms[best] ? k : best));
149
+ const highlights = [...owned, third].map((key) => ({ key, value: values[key], caption: CAPTIONS[key] }));
150
+
151
+ return {
152
+ days: w.days,
153
+ character,
154
+ name: NAMES[character],
155
+ sentence: sentences[character],
156
+ motto: MOTTOS[character],
157
+ shares,
158
+ peak: { index: peakBucket.score.index, level: peakBucket.score.level },
159
+ spectrum,
160
+ highlights,
161
+ };
162
+ }
@@ -0,0 +1,133 @@
1
+ // The card page: one self-contained HTML document from CardData and the embedded
2
+ // assets. Pure: strings in, string out. Nothing is escaped because nothing from a
3
+ // transcript reaches this file: only CardData's fixed strings and formatted numbers.
4
+ // The look is the table in docs/superpowers/specs/2026-09-18-zapara-card-design.md.
5
+ import type { CardData, Character, Segment } from "./card.ts";
6
+ import type { Level } from "./types.ts";
7
+
8
+ export type CardAssets = {
9
+ fonts: { inter400: string; inter700: string; inter800: string; mono500: string };
10
+ characters: string;
11
+ };
12
+
13
+ // Where each character sits on the sheet, as fractions [x, y, w, h] of its side,
14
+ // measured on the current assets/characters.webp. The box is scaled so the
15
+ // rectangle's longer side is BOX px and centred at CENTRE, with CSS background-size
16
+ // and background-position, so no cropping happens anywhere.
17
+ export const CHARACTER_RECTS: Record<Character, [number, number, number, number]> = {
18
+ conductor: [0.02, 0.01, 0.53, 0.543],
19
+ supervisor: [0.55, 0.07, 0.38, 0.505],
20
+ marathoner: [0.02, 0.553, 0.50, 0.437],
21
+ nightOwl: [0.54, 0.585, 0.45, 0.41],
22
+ };
23
+ const BOX = 360;
24
+ const CENTRE = { x: 195, y: 300 };
25
+
26
+ // Streak accent, its rgb for alpha gradients, and the light shade for the repo link.
27
+ const ACCENT: Record<Character, { main: string; rgb: string; light: string }> = {
28
+ conductor: { main: "#8b5cf6", rgb: "139,92,246", light: "#c4b5fd" },
29
+ supervisor: { main: "#22d3ee", rgb: "34,211,238", light: "#a5f3fc" },
30
+ marathoner: { main: "#f59e0b", rgb: "245,158,11", light: "#fde68a" },
31
+ nightOwl: { main: "#60a5fa", rgb: "96,165,250", light: "#bfdbfe" },
32
+ };
33
+ const LEVEL_CLASS: Record<Level, string> = { Calm: "calm", Warming: "warm", Heating: "heat", Fried: "fried" };
34
+
35
+ function spriteStyle(c: Character): string {
36
+ const [x, y, w, h] = CHARACTER_RECTS[c];
37
+ const size = BOX / Math.max(w, h);
38
+ const bw = w * size;
39
+ const bh = h * size;
40
+ const px = (v: number): string => `${v.toFixed(1)}px`;
41
+ return `background-size:${px(size)} ${px(size)};background-position:${px(-x * size)} ${px(-y * size)};width:${px(bw)};height:${px(bh)};left:${px(CENTRE.x - bw / 2)};top:${px(CENTRE.y - bh / 2)}`;
42
+ }
43
+ const sentenceHtml = (segments: Segment[]): string => segments.map((s) => (s.strong ? `<b>${s.text}</b>` : s.text)).join("");
44
+ const fontFace = (family: string, weight: number, data: string): string =>
45
+ `@font-face{font-family:"${family}";font-weight:${weight};font-style:normal;src:url("data:font/woff2;base64,${data}") format("woff2")}`;
46
+
47
+ const GRAIN = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='300'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/></filter><rect width='300' height='300' filter='url(%23n)'/></svg>")`;
48
+
49
+ export function cardHtml(card: CardData, assets: CardAssets): string {
50
+ const accent = ACCENT[card.character];
51
+ const levels = [
52
+ ["calm", card.spectrum.calm, "calm"],
53
+ ["warm", card.spectrum.warming, "warming"],
54
+ ["heat", card.spectrum.heating, "heating"],
55
+ ["fried", card.spectrum.fried, "fried"],
56
+ ] as const;
57
+ const bar = levels.filter(([, pct]) => pct > 0).map(([cls, pct]) => `<div class="${cls}" style="width:${pct}%"></div>`).join("");
58
+ const legend = levels.map(([cls, pct, name]) => `<span><i class="${cls}"></i><b>${pct}%</b> ${name}</span>`).join("&nbsp;&nbsp;");
59
+ const stats = card.highlights
60
+ .map((h) => `<div class="stat" data-key="${h.key}"><div class="v">${h.value}</div><div class="c mono">${h.caption}</div></div>`)
61
+ .join("");
62
+ const label = card.days === 1 ? "Last 1 day" : `Last ${card.days} days`;
63
+
64
+ return `<!doctype html>
65
+ <html><head><meta charset="utf-8"><title>zapara card</title>
66
+ <style>
67
+ ${fontFace("Inter", 400, assets.fonts.inter400)}
68
+ ${fontFace("Inter", 700, assets.fonts.inter700)}
69
+ ${fontFace("Inter", 800, assets.fonts.inter800)}
70
+ ${fontFace("JetBrains Mono", 500, assets.fonts.mono500)}
71
+ :root{--ink:#f5f5f7;--muted:#a1a1aa;--dim:#6b6b76;--line:rgba(255,255,255,.10);--accent:${accent.main};--accent2:${accent.light};--accent-rgb:${accent.rgb};--calm:#7ee2a3;--warm:#fbd77a;--heat:#c4a0ff;--fried:#ff6b8f}
72
+ html{zoom:2}
73
+ html,body{margin:0;background:#000}
74
+ .card{position:relative;width:1200px;height:630px;overflow:hidden;font-family:Inter,-apple-system,system-ui,sans-serif;color:var(--ink);-webkit-font-smoothing:antialiased;background:#07070a}
75
+ .mono{font-family:"JetBrains Mono",ui-monospace,monospace}
76
+ .streaks{position:absolute;left:-200px;top:-260px;width:900px;height:1100px;transform:rotate(38deg);filter:blur(22px);opacity:.9}
77
+ .streaks div{position:absolute;top:0;height:100%;border-radius:40px}
78
+ .s1{left:120px;width:150px;background:linear-gradient(180deg,transparent 0%,rgba(var(--accent-rgb),.55) 35%,rgba(var(--accent-rgb),.9) 50%,rgba(var(--accent-rgb),.45) 70%,transparent 100%)}
79
+ .s2{left:320px;width:70px;background:linear-gradient(180deg,transparent 10%,rgba(255,255,255,.35) 45%,rgba(var(--accent-rgb),.7) 55%,transparent 90%)}
80
+ .s3{left:440px;width:200px;background:linear-gradient(180deg,transparent 0%,rgba(var(--accent-rgb),.35) 40%,rgba(34,211,238,.35) 58%,transparent 100%)}
81
+ .s4{left:700px;width:90px;background:linear-gradient(180deg,transparent 15%,rgba(var(--accent-rgb),.5) 50%,transparent 85%)}
82
+ .vignette{position:absolute;inset:0;background:radial-gradient(900px 600px at 30% 40%,transparent 30%,rgba(7,7,10,.85) 75%,#07070a 100%)}
83
+ .grain{position:absolute;inset:0;opacity:.35;mix-blend-mode:overlay;background-image:${GRAIN}}
84
+ .char{position:absolute;background-image:url("data:image/webp;base64,${assets.characters}");background-repeat:no-repeat;filter:drop-shadow(0 30px 40px rgba(0,0,0,.7))}
85
+ .panel{position:absolute;left:380px;top:56px;width:760px;height:518px;box-sizing:border-box;border-radius:20px;border:1px solid var(--line);background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.02));box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 30px 80px rgba(0,0,0,.5);padding:36px 40px}
86
+ .row{display:flex;justify-content:space-between;align-items:center}
87
+ .label{font-size:13px;color:var(--dim);letter-spacing:.5px;text-transform:uppercase}
88
+ .peak{display:inline-flex;align-items:center;gap:10px;padding:6px 12px;border-radius:8px;border:1px solid var(--line);background:rgba(255,255,255,.03);font-size:12px;color:var(--dim);letter-spacing:.5px;text-transform:uppercase;white-space:nowrap}
89
+ .peak b{font-weight:500}
90
+ .peak b.calm{color:var(--calm)}.peak b.warm{color:var(--warm)}.peak b.heat{color:var(--heat)}.peak b.fried{color:var(--fried)}
91
+ .name{margin-top:18px;font-size:76px;font-weight:700;line-height:1;letter-spacing:-3px;color:#fff;text-shadow:0 0 40px rgba(255,255,255,.18);white-space:nowrap}
92
+ .sentence{margin-top:22px;font-size:22px;line-height:31px;color:var(--muted);letter-spacing:-.2px;max-width:660px}
93
+ .sentence b{color:#fff;font-weight:600}
94
+ .divider{height:1px;background:var(--line);margin:30px 0 26px}
95
+ .bar{height:8px;border-radius:4px;overflow:hidden;display:flex;gap:2px}
96
+ .bar div{height:100%}
97
+ .bar .calm{background:var(--calm)}.bar .warm{background:var(--warm)}.bar .heat{background:var(--heat)}.bar .fried{background:var(--fried)}
98
+ .legend{margin-top:14px;font-size:12.5px;color:var(--dim);letter-spacing:.2px;white-space:nowrap}
99
+ .legend b{color:var(--muted);font-weight:500}
100
+ .legend i{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 7px 1px 0}
101
+ .legend i.calm{background:var(--calm)}.legend i.warm{background:var(--warm)}.legend i.heat{background:var(--heat)}.legend i.fried{background:var(--fried)}
102
+ .stats{display:flex;gap:16px;margin-top:30px}
103
+ .stat{flex:1;min-width:0;box-sizing:border-box;padding:18px 16px;border-radius:12px;border:1px solid var(--line);background:rgba(255,255,255,.025);box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}
104
+ .stat .v{font-size:38px;font-weight:700;letter-spacing:-1.6px;line-height:1;color:#fff;font-variant-numeric:tabular-nums;white-space:nowrap}
105
+ .stat .c{margin-top:8px;font-size:12px;color:var(--dim);letter-spacing:0;white-space:nowrap}
106
+ .repo{position:absolute;left:60px;top:566px;line-height:1;white-space:nowrap}
107
+ .repo small{display:block;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);margin-bottom:8px}
108
+ .repo span{font-size:17px;font-weight:500;color:var(--accent2);letter-spacing:-.2px}
109
+ .source{position:absolute;right:60px;top:588px;font-size:12px;color:var(--muted);letter-spacing:.2px;white-space:nowrap}
110
+ </style></head><body>
111
+ <div class="card">
112
+ <div class="streaks"><div class="s1"></div><div class="s2"></div><div class="s3"></div><div class="s4"></div></div>
113
+ <div class="vignette"></div>
114
+ <div class="grain"></div>
115
+ <div class="char ${card.character}" style="${spriteStyle(card.character)}"></div>
116
+ <div class="panel">
117
+ <div class="row">
118
+ <div class="label mono">${label}</div>
119
+ <div class="peak mono">Peak hour <b class="${LEVEL_CLASS[card.peak.level]}">${card.peak.index} · ${card.peak.level}</b></div>
120
+ </div>
121
+ <div class="name">${card.name}</div>
122
+ <div class="sentence">${sentenceHtml(card.sentence)} ${card.motto}</div>
123
+ <div class="divider"></div>
124
+ <div class="bar">${bar}</div>
125
+ <div class="legend mono">${legend}</div>
126
+ <div class="stats">${stats}</div>
127
+ </div>
128
+ <div class="repo mono"><small>Get yours</small><span>github.com/drakulavich/zapara</span></div>
129
+ <div class="source mono">computed locally from your Claude Code transcripts · nothing leaves your machine</div>
130
+ </div>
131
+ </body></html>
132
+ `;
133
+ }
package/src/derive.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { score } from "./score.ts";
2
+ import type { Day, Event, HourBucket, Metrics, Totals, Window } from "./types.ts";
3
+
4
+ export const LOOKBACK_MS = 3 * 60 * 60 * 1000;
5
+ export const GAP_MS = 10 * 60 * 1000;
6
+ export const SLOT_MS = 5 * 60 * 1000;
7
+ const LATE_HOURS = new Set([23, 0, 1, 2, 3, 4, 5]);
8
+
9
+ const pad2 = (n: number) => String(n).padStart(2, "0");
10
+ export const localDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
11
+ const parseDate = (s: string): Date => {
12
+ const [y, m, d] = s.split("-").map(Number) as [number, number, number];
13
+ return new Date(y, m - 1, d); // local midnight
14
+ };
15
+
16
+ export function windowBounds(w: Window): { startMs: number; endMs: number; cutoffMs: number; dates: string[] } {
17
+ const to = parseDate(w.to);
18
+ const dates: string[] = [];
19
+ for (let i = w.days - 1; i >= 0; i--) {
20
+ const d = new Date(to.getFullYear(), to.getMonth(), to.getDate() - i);
21
+ dates.push(localDate(d));
22
+ }
23
+ const startMs = parseDate(dates[0]!).getTime();
24
+ const endMs = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1).getTime();
25
+ return { startMs, endMs, cutoffMs: startMs - LOOKBACK_MS, dates };
26
+ }
27
+
28
+ const emptyMetrics = (): Metrics => ({
29
+ sessions: 0, prompts: 0, reports: 0, outputTokens: 0, interrupts: 0, rejects: 0, questions: 0, plans: 0, modeSwitches: 0,
30
+ decisions: 0, contextSwitches: 0, activeMin: 0, streakMin: 0, lateNight: false,
31
+ });
32
+
33
+ type Acc = { m: Metrics; sessions: Set<string>; slots: Set<number>; lastPromptSession: string | null; lastActivity: { ts: number; streakStart: number } | null };
34
+
35
+ // Walks the sorted, look-back-filtered events once, keyed by "date|hour",
36
+ // tracking the running activity streak (which may start before startMs) and
37
+ // accumulating each bucket's raw counts. Events before startMs update the
38
+ // streak only; they are never attributed to a bucket.
39
+ function foldEvents(sorted: Event[], startMs: number): Map<string, Acc> {
40
+ const acc = new Map<string, Acc>(); // key "date|hour"
41
+ const key = (ts: number): string => {
42
+ const d = new Date(ts);
43
+ return `${localDate(d)}|${d.getHours()}`;
44
+ };
45
+ const get = (k: string): Acc => {
46
+ let a = acc.get(k);
47
+ if (!a) { a = { m: emptyMetrics(), sessions: new Set(), slots: new Set(), lastPromptSession: null, lastActivity: null }; acc.set(k, a); }
48
+ return a;
49
+ };
50
+
51
+ let prevActivityTs: number | null = null;
52
+ let streakStart: number | null = null;
53
+ for (const e of sorted) {
54
+ if (e.kind === "activity") {
55
+ streakStart = prevActivityTs === null || e.ts - prevActivityTs > GAP_MS ? e.ts : streakStart;
56
+ prevActivityTs = e.ts;
57
+ }
58
+ if (e.ts < startMs) continue; // look-back: streak bookkeeping only
59
+ const a = get(key(e.ts));
60
+ switch (e.kind) {
61
+ case "activity":
62
+ a.sessions.add(e.sessionId);
63
+ a.slots.add(Math.floor(e.ts / SLOT_MS));
64
+ a.lastActivity = { ts: e.ts, streakStart: streakStart! };
65
+ break;
66
+ case "prompt":
67
+ a.m.prompts++;
68
+ if (a.lastPromptSession !== null && a.lastPromptSession !== e.sessionId) a.m.contextSwitches++;
69
+ a.lastPromptSession = e.sessionId;
70
+ break;
71
+ case "report": a.m.reports++; break;
72
+ case "output": a.m.outputTokens += e.tokens ?? 0; break;
73
+ case "interrupt": a.m.interrupts++; break;
74
+ case "reject": a.m.rejects++; break;
75
+ case "question": a.m.questions++; break;
76
+ case "plan_review": a.m.plans++; break;
77
+ case "mode_change": a.m.modeSwitches++; break;
78
+ }
79
+ }
80
+ return acc;
81
+ }
82
+
83
+ // Builds one Day's 24 hour buckets from the accumulated counts, scores each,
84
+ // and rolls up totals, peak and mean.
85
+ function buildDay(date: string, acc: Map<string, Acc>): Day {
86
+ const buckets: HourBucket[] = [];
87
+ for (let hour = 0; hour < 24; hour++) {
88
+ const a = acc.get(`${date}|${hour}`);
89
+ const m = a ? a.m : emptyMetrics();
90
+ if (a) {
91
+ m.sessions = a.sessions.size;
92
+ m.activeMin = a.slots.size * 5;
93
+ m.streakMin = a.lastActivity ? Math.round((a.lastActivity.ts - a.lastActivity.streakStart) / 60000) : 0;
94
+ }
95
+ m.decisions = m.interrupts + m.rejects + m.questions + m.plans + m.modeSwitches;
96
+ // lateNight is a property of the hour label, so it is set on every bucket,
97
+ // including empty ones; score() returns null for buckets without
98
+ // sessions, so an empty late hour scores nothing.
99
+ m.lateNight = LATE_HOURS.has(hour);
100
+ buckets.push({ ...m, hour, score: score(m) });
101
+ }
102
+ const scored = buckets.filter((b) => b.score !== null);
103
+ const totals: Totals = buckets.reduce((t, b) => ({
104
+ prompts: t.prompts + b.prompts, reports: t.reports + b.reports, outputTokens: t.outputTokens + b.outputTokens,
105
+ interrupts: t.interrupts + b.interrupts, rejects: t.rejects + b.rejects,
106
+ questions: t.questions + b.questions, plans: t.plans + b.plans, modeSwitches: t.modeSwitches + b.modeSwitches,
107
+ decisions: t.decisions + b.decisions, contextSwitches: t.contextSwitches + b.contextSwitches,
108
+ maxSessions: Math.max(t.maxSessions, b.sessions),
109
+ }), { prompts: 0, reports: 0, outputTokens: 0, interrupts: 0, rejects: 0, questions: 0, plans: 0, modeSwitches: 0, decisions: 0, contextSwitches: 0, maxSessions: 0 });
110
+ return {
111
+ date,
112
+ peak: scored.length ? Math.max(...scored.map((b) => b.score!.index)) : null,
113
+ mean: scored.length ? Math.round(scored.reduce((s, b) => s + b.score!.index, 0) / scored.length) : null,
114
+ activeMin: buckets.reduce((s, b) => s + b.activeMin, 0),
115
+ totals,
116
+ buckets,
117
+ };
118
+ }
119
+
120
+ export function derive(events: Event[], w: Window): Day[] {
121
+ const { startMs, endMs, cutoffMs, dates } = windowBounds(w);
122
+ const sorted = events
123
+ .map((e, i) => ({ e, i }))
124
+ .filter(({ e }) => e.ts >= cutoffMs && e.ts < endMs)
125
+ .sort((a, b) => a.e.ts - b.e.ts || (a.e.sessionId < b.e.sessionId ? -1 : a.e.sessionId > b.e.sessionId ? 1 : 0) || a.i - b.i)
126
+ .map(({ e }) => e);
127
+ const acc = foldEvents(sorted, startMs);
128
+ return dates.map((date) => buildDay(date, acc));
129
+ }
package/src/format.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Compact number formats shared by the card and the week footer. Pure: no
2
+ // imports from node:/Bun, no clock, no file system.
3
+
4
+ // Compact formats with a fixed longest form of five characters, so the layout
5
+ // is sized once. Decimals are truncated, not rounded: 9.96M stays "9.9M".
6
+ function ladder(n: number): string {
7
+ if (n >= 1e12) return "999B+";
8
+ for (const [unit, size] of [["B", 1e9], ["M", 1e6], ["k", 1e3]] as const) {
9
+ if (n < size) continue;
10
+ const v = n / size;
11
+ if (unit !== "k" && v < 10) return `${(Math.floor(v * 10) / 10).toFixed(1)}${unit}`;
12
+ return `${Math.floor(v)}${unit}`;
13
+ }
14
+ return String(n);
15
+ }
16
+ export const formatCount = (n: number): string => (n < 10_000 ? String(n) : ladder(n));
17
+ export const formatTokens = (n: number): string => (n < 1000 ? String(n) : ladder(n));
18
+
19
+ // "1 session", "5 sessions": a count with its noun, singular only for exactly one.
20
+ export const plural = (n: number, one: string, many = `${one}s`, count = formatCount): string => `${count(n)} ${n === 1 ? one : many}`;
package/src/image.ts ADDED
@@ -0,0 +1,70 @@
1
+ // The card's shell: the only module that reads the card assets, opens a
2
+ // Bun.WebView or a Bun.Image, and writes a file. Everything it writes is the one
3
+ // file the person named; nothing here prints.
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import type { CardAssets } from "./cardhtml.ts";
6
+
7
+ const ASSETS = new URL("../assets/", import.meta.url);
8
+ const FILES = ["fonts/inter-400.woff2", "fonts/inter-700.woff2", "fonts/inter-800.woff2", "fonts/jetbrains-mono-500.woff2", "characters.webp"] as const;
9
+
10
+ // Reads the five files next to the source. A missing, unreadable or empty one is
11
+ // a broken install, reported without a path: the CLI never prints one.
12
+ export async function loadAssets(): Promise<CardAssets> {
13
+ let parts: string[];
14
+ try {
15
+ parts = await Promise.all(FILES.map(async (name) => (await readFile(new URL(name, ASSETS))).toString("base64")));
16
+ } catch {
17
+ throw new Error("assets missing: reinstall zapara");
18
+ }
19
+ const [inter400, inter700, inter800, mono500, characters] = parts;
20
+ if (parts.some((p) => p.length === 0)) throw new Error("assets missing: reinstall zapara");
21
+ return { fonts: { inter400: inter400!, inter700: inter700!, inter800: inter800!, mono500: mono500! }, characters: characters! };
22
+ }
23
+
24
+ const BACKEND = process.platform === "darwin" ? "webkit" : "chrome";
25
+ const ENGINE_LINE = "card needs a browser engine: install Google Chrome, or write --out card.html";
26
+ const WIDTH = 2400;
27
+ const HEIGHT = 1260;
28
+ const READY = 'document.fonts.ready.then(() => document.fonts.status === "loaded" && Array.from(document.images).every((i) => i.complete))';
29
+
30
+ // Writes the page as is for `.html`; otherwise photographs it at 2400x1260 and
31
+ // writes PNG or WebP. The 15s budget bounds the whole render (construct, navigate,
32
+ // poll, screenshot, resize, encode), raced against a single timer; the view is
33
+ // closed on every path. Any engine failure (constructor, navigate, evaluate,
34
+ // screenshot) is mapped to one line that never quotes the engine's own text; the
35
+ // timeout error passes through unchanged. `writeFile` failures are the one
36
+ // exception, left unmapped, so they keep reporting the user's own `--out` string.
37
+ export async function renderCard(html: string, out: string, timeoutMs = 15_000): Promise<void> {
38
+ const lower = out.toLowerCase();
39
+ if (lower.endsWith(".html")) {
40
+ await writeFile(out, html);
41
+ return;
42
+ }
43
+ let bytes: Uint8Array;
44
+ let timer: ReturnType<typeof setTimeout>;
45
+ try {
46
+ bytes = await Promise.race([
47
+ (async () => {
48
+ const view = new Bun.WebView({ width: WIDTH, height: HEIGHT, backend: BACKEND });
49
+ try {
50
+ await view.navigate("data:text/html;charset=utf-8," + encodeURIComponent(html));
51
+ while (!(await view.evaluate<boolean>(READY))) await Bun.sleep(50);
52
+ const shot = await view.screenshot({ encoding: "buffer", format: "png" });
53
+ const image = new Bun.Image(shot);
54
+ const meta = await image.metadata();
55
+ if (meta.width !== WIDTH || meta.height !== HEIGHT) image.resize(WIDTH, HEIGHT, { fit: "fill" });
56
+ return lower.endsWith(".webp") ? await image.webp({ quality: 90 }).bytes() : await image.png().bytes();
57
+ } finally {
58
+ view.close();
59
+ }
60
+ })(),
61
+ new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error("render timed out")), timeoutMs); }),
62
+ ]);
63
+ } catch (e) {
64
+ if (e instanceof Error && e.message === "render timed out") throw e;
65
+ throw new Error(ENGINE_LINE);
66
+ } finally {
67
+ clearTimeout(timer!);
68
+ }
69
+ await writeFile(out, bytes);
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bun
2
+ // zapara CLI. Argument parsing, the clock, stdout and exit codes live here; everything else is pure.
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { cardData, sentenceText } from "./card.ts";
7
+ import { cardHtml } from "./cardhtml.ts";
8
+ import { localDate } from "./derive.ts";
9
+ import { loadAssets, renderCard } from "./image.ts";
10
+ import { renderDay, renderJson, renderWeek } from "./render.ts";
11
+ import { report } from "./report.ts";
12
+ import type { Day } from "./types.ts";
13
+
14
+ // Read lazily, only when --version is actually handled, so a broken install
15
+ // (missing or corrupt package.json) fails inside the guarded catch below
16
+ // instead of throwing at module load, before any try/catch is in place.
17
+ function version(): string {
18
+ const parsed: unknown = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
+ if (typeof parsed === "object" && parsed !== null && typeof (parsed as { version?: unknown }).version === "string") {
20
+ return (parsed as { version: string }).version;
21
+ }
22
+ throw new Error("package.json has no version");
23
+ }
24
+
25
+ const USAGE = `usage: zapara [week] [--days N] [--to YYYY-MM-DD]
26
+ zapara day [YYYY-MM-DD] [--explain]
27
+ zapara card [--days N] [--to YYYY-MM-DD] [--out PATH.png|.webp|.html]
28
+ flags: --json --projects <dir> --no-color --help --version
29
+ levels: calm 0-29 warming 30-59 heating 60-84 fried 85-100`;
30
+
31
+ type Args = { command: "week" | "day" | "card"; to: string; days: number; date: string | null; explain: boolean; json: boolean; out: string; projects: string; color: boolean };
32
+
33
+ class UsageError extends Error {}
34
+ // Thrown only at a flag position (never when a token was consumed as another
35
+ // flag's value, e.g. `--to --help`), so `main()` can short-circuit to exit 0
36
+ // without parseArgs having to also validate the rest of a help/version call.
37
+ class HelpRequested extends Error {}
38
+ class VersionRequested extends Error {}
39
+
40
+ function validDate(s: string): boolean {
41
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
42
+ if (!m) return false;
43
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
44
+ const dt = new Date(y, mo - 1, d);
45
+ return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
46
+ }
47
+
48
+ function parseArgs(argv: string[], now: Date, env: NodeJS.ProcessEnv, isTTY: boolean): Args {
49
+ const a: Args = { command: "week", to: localDate(now), days: 7, date: null, explain: false, json: false, out: "zapara-card.png", projects: join(homedir(), ".claude", "projects"), color: isTTY && !env.NO_COLOR };
50
+ let days: number | null = null;
51
+ let jsonFlag = false;
52
+ let outGiven = false;
53
+ const positional: string[] = [];
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const arg = argv[i]!;
56
+ // A missing value or one that looks like another flag is a usage error,
57
+ // never treated as this flag's value (e.g. `--projects --json`).
58
+ const value = (): string => { const v = argv[++i]; if (v === undefined || v.startsWith("-")) throw new UsageError(`${arg} needs a value`); return v; };
59
+ switch (arg) {
60
+ case "--help":
61
+ case "-h": throw new HelpRequested();
62
+ case "--version": throw new VersionRequested();
63
+ case "--json": jsonFlag = true; break;
64
+ case "--explain": a.explain = true; break;
65
+ case "--no-color": a.color = false; break;
66
+ case "--projects": a.projects = value(); break;
67
+ case "--to": a.to = value(); break;
68
+ case "--days": { const v = value(); if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 90) throw new UsageError(`--days must be 1..90, got ${v}`); days = Number(v); break; }
69
+ case "--out": a.out = value(); outGiven = true; break;
70
+ default:
71
+ if (arg.startsWith("-")) throw new UsageError(`unknown flag ${arg}`);
72
+ positional.push(arg);
73
+ }
74
+ }
75
+ const [cmd, ...rest] = positional;
76
+ if (cmd === undefined || cmd === "week") { if (rest.length) throw new UsageError(`unexpected argument ${rest[0]}`); a.command = "week"; }
77
+ else if (cmd === "day") { a.command = "day"; a.date = rest[0] ?? a.to; if (rest.length > 1) throw new UsageError(`unexpected argument ${rest[1]}`); }
78
+ else if (cmd === "card") { a.command = "card"; if (rest.length) throw new UsageError(`unexpected argument ${rest[0]}`); }
79
+ else throw new UsageError(`unknown command ${cmd}`);
80
+ // Two weeks make a pattern; a week makes a picture of one week.
81
+ a.days = days ?? (a.command === "card" ? 14 : 7);
82
+ // Tables turn into JSON in a pipe; the card is a file either way, so only an explicit --json switches it.
83
+ a.json = a.command === "card" ? jsonFlag : jsonFlag || !isTTY;
84
+ if (a.command !== "day" && a.explain) throw new UsageError("--explain applies to day only");
85
+ if (a.command !== "card" && outGiven) throw new UsageError("--out applies to card only");
86
+ // The value is printed back verbatim in `wrote …`, so it must be one plain line:
87
+ // no control character, and the message never quotes it.
88
+ if (/[\x00-\x1f\x7f]/.test(a.out)) throw new UsageError("--out must not contain control characters");
89
+ if (!/\.(png|webp|html)$/i.test(a.out)) throw new UsageError("--out must end in .png, .webp or .html");
90
+ if (!validDate(a.to)) throw new UsageError(`--to must be YYYY-MM-DD, got ${a.to}`);
91
+ if (a.date !== null && !validDate(a.date)) throw new UsageError(`date must be YYYY-MM-DD, got ${a.date}`);
92
+ return a;
93
+ }
94
+
95
+ async function main(): Promise<number> {
96
+ const argv = process.argv.slice(2);
97
+ const a = parseArgs(argv, new Date(), process.env, process.stdout.isTTY === true);
98
+ if (a.command === "card") return card(a);
99
+ const days: Day[] = a.command === "day"
100
+ ? await report({ projects: a.projects, to: a.date!, days: 1 })
101
+ : await report({ projects: a.projects, to: a.to, days: a.days });
102
+ const data = a.command === "day" ? days[0] : days;
103
+ if (a.json) console.log(renderJson(data!));
104
+ else if (a.command === "day") console.log(renderDay(days[0]!, { explain: a.explain, color: a.color }));
105
+ else console.log(renderWeek(days, a.color));
106
+ return 0;
107
+ }
108
+
109
+ async function card(a: Args): Promise<number> {
110
+ const days: Day[] = await report({ projects: a.projects, to: a.to, days: a.days });
111
+ const data = cardData(days, { days: a.days });
112
+ if (data === null) throw new Error(`no activity in the last ${a.days} days`);
113
+ if (a.json) {
114
+ const round2 = (x: number): number => Math.round(x * 100) / 100;
115
+ const json = {
116
+ from: days[0]!.date, to: days[days.length - 1]!.date, days: data.days, character: data.character, name: data.name,
117
+ sentence: sentenceText(data.sentence), motto: data.motto,
118
+ shares: { conductor: round2(data.shares.conductor), supervisor: round2(data.shares.supervisor), marathoner: round2(data.shares.marathoner), nightOwl: round2(data.shares.nightOwl) },
119
+ peak: data.peak, spectrum: data.spectrum, highlights: data.highlights,
120
+ };
121
+ console.log(JSON.stringify(json, null, 2));
122
+ return 0;
123
+ }
124
+ await renderCard(cardHtml(data, await loadAssets()), a.out);
125
+ console.log(`${data.name}: ${sentenceText(data.sentence)}\nwrote ${a.out}`);
126
+ return 0;
127
+ }
128
+
129
+ if (import.meta.main) {
130
+ main().then((code) => process.exit(code), (e: unknown) => {
131
+ if (e instanceof HelpRequested) { console.log(USAGE); process.exit(0); }
132
+ if (e instanceof VersionRequested) {
133
+ try { console.log(version()); process.exit(0); }
134
+ catch (err) { console.error(`zapara: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); }
135
+ }
136
+ const msg = e instanceof Error ? e.message : String(e);
137
+ if (e instanceof UsageError) { console.error(`zapara: ${msg}\n${USAGE}`); process.exit(2); }
138
+ console.error(`zapara: ${msg}`);
139
+ process.exit(1);
140
+ });
141
+ }