@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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/assets/characters.webp +0 -0
- package/assets/fonts/LICENSE-inter.txt +92 -0
- package/assets/fonts/LICENSE-jetbrains-mono.txt +93 -0
- package/assets/fonts/inter-400.woff2 +0 -0
- package/assets/fonts/inter-700.woff2 +0 -0
- package/assets/fonts/inter-800.woff2 +0 -0
- package/assets/fonts/jetbrains-mono-500.woff2 +0 -0
- package/package.json +27 -0
- package/src/analyze.ts +12 -0
- package/src/card.ts +162 -0
- package/src/cardhtml.ts +133 -0
- package/src/derive.ts +129 -0
- package/src/format.ts +20 -0
- package/src/image.ts +70 -0
- package/src/index.ts +141 -0
- package/src/parse.ts +105 -0
- package/src/render.ts +110 -0
- package/src/report.ts +18 -0
- package/src/scan.ts +40 -0
- package/src/score.ts +58 -0
- package/src/types.ts +15 -0
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
|
+
}
|
package/src/cardhtml.ts
ADDED
|
@@ -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(" ");
|
|
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
|
+
}
|