@seanmars/tospec 0.14.0 → 0.14.2

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.
@@ -0,0 +1,995 @@
1
+ // tospec skill-metrics — vanilla page. No framework, no build, no external resources.
2
+ // Charts: vendored Chart.js (assets/chart.umd.js), themed via CSS variables.
3
+ //
4
+ // This file holds no statistics: every total, median, and bucket arrives
5
+ // already computed, so there is no second definition of any summary to drift
6
+ // from the server's.
7
+
8
+ const view = document.getElementById('view');
9
+
10
+ const MS_PER_MINUTE = 60000;
11
+
12
+ // --- persisted preferences -------------------------------------------------
13
+ //
14
+ // With the command's data flags gone this is the only channel a preference has:
15
+ // without it, anyone who keeps split-gap at 60 would re-set four controls on
16
+ // every invocation.
17
+
18
+ const PREFS_KEY = 'tospec-metrics';
19
+
20
+ function loadPrefs() {
21
+ try {
22
+ return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function savePrefs(prefs) {
29
+ try {
30
+ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
31
+ } catch {
32
+ // Private browsing or a full quota: preferences are a convenience, never a
33
+ // precondition for the page working.
34
+ }
35
+ }
36
+
37
+ // --- language --------------------------------------------------------------
38
+ //
39
+ // Every string the page shows lives here. Skill names are identifiers and are
40
+ // never translated — they name real files and commands.
41
+ //
42
+ // Traditional Chinese (Taiwan) uses half-width punctuation, matching the rest of
43
+ // the project's prose.
44
+
45
+ const STRINGS = {
46
+ en: {
47
+ 'lang.name': 'English',
48
+ 'control.range': 'Range',
49
+ 'control.idleGap': 'Idle gap (min)',
50
+ 'control.splitGap': 'Split gap (min)',
51
+ 'control.workflowsOnly': 'tospec workflows only',
52
+ 'control.theme': 'Theme',
53
+ 'control.language': 'Language',
54
+ 'control.refresh': '↻ Refresh',
55
+ 'range.30': 'Last 30 days',
56
+ 'range.90': 'Last 90 days',
57
+ 'range.365': 'Last year',
58
+ 'range.all': 'All time',
59
+ 'theme.system': 'System',
60
+ 'theme.light': 'Light',
61
+ 'theme.dark': 'Dark',
62
+ 'theme.dracula': 'Dracula',
63
+ 'chart.totals.title': 'Total time by skill',
64
+ 'chart.totals.note': 'Solid is engaged time; the recessed part is idle — pauses inside a run.',
65
+ 'chart.totals.axis': 'minutes — ordered by engaged time',
66
+ 'chart.median.title': 'Typical run length',
67
+ 'chart.median.note':
68
+ 'The middle run of each skill. A skill can top the totals above and sit low here — that is a lot of short runs, not a slow one.',
69
+ 'chart.median.axis': 'median minutes per run — ordered by engaged',
70
+ 'chart.distribution.title': 'Per-run distribution',
71
+ 'chart.distribution.note':
72
+ 'Every dot is one run. The bar spans shortest to longest; the solid part is the engaged range.',
73
+ 'chart.distribution.axis': 'minutes per run',
74
+ 'chart.series.title.day': 'Time per day',
75
+ 'chart.series.title.week': 'Time per week',
76
+ 'chart.series.title.month': 'Time per month',
77
+ 'chart.series.note.day': 'Local calendar time. A run counts whole on the day it started.',
78
+ 'chart.series.note.week': 'Local calendar time. A run counts whole on the week it started.',
79
+ 'chart.series.note.month': 'Local calendar time. A run counts whole on the month it started.',
80
+ 'chart.series.axis': 'engaged minutes',
81
+ 'chart.timeline.title': 'Run timeline',
82
+ 'chart.timeline.note':
83
+ 'One bubble per run; its area is engaged time. Overlapping runs are nudged apart, always by the same amount.',
84
+ 'series.engaged': 'engaged',
85
+ 'series.idle': 'idle',
86
+ 'series.medianSpan': 'median span',
87
+ 'series.medianEngaged': 'median engaged',
88
+ 'series.spanRange': 'span range',
89
+ 'series.engagedRange': 'engaged range',
90
+ 'series.eachRun': 'each run',
91
+ 'empty.noHistory.title': 'No history for this project',
92
+ 'empty.noHistory.body': '{tool} — looked in: {dir}',
93
+ 'empty.noAttribution.title': 'No skill runs recorded',
94
+ 'empty.noAttribution.body': 'Scanned {sessions} session(s), none with skill attribution.',
95
+ 'empty.noAttribution.hint':
96
+ 'Skill attribution only appears in recent sessions; older ones cannot be measured.',
97
+ 'empty.noRange.title': 'Nothing in this range',
98
+ 'empty.noRange.body':
99
+ 'The transcripts hold {sessions} attributed session(s) — this time range just has none of them.',
100
+ 'empty.allHidden.title': 'Everything is hidden',
101
+ 'empty.allHidden.body':
102
+ 'No tospec workflow ran in this range. Untick "tospec workflows only" to see the rest.',
103
+ 'error.title': 'Could not load the report',
104
+ 'footer.source':
105
+ '{tool}: derived from {attributed}/{sessions} session(s) with skill attribution{span}.',
106
+ 'footer.noSource': '{tool}: no history found for this project.',
107
+ 'footer.codexCaveat': 'Skill attribution here is inferred from SKILL.md reads, not recorded directly.',
108
+ 'footer.sourceSpan': ', {first} to {last}',
109
+ 'footer.definition':
110
+ 'A run is one stretch of a single skill, uninterrupted by another skill and with no pause longer than the split gap ({split} min). Idle is the time inside a run spent paused for longer than the idle gap ({idle} min).',
111
+ },
112
+ 'zh-TW': {
113
+ 'lang.name': '正體中文',
114
+ 'control.range': '時間範圍',
115
+ 'control.idleGap': '閒置間隔 (分)',
116
+ 'control.splitGap': '切分間隔 (分)',
117
+ 'control.workflowsOnly': '只看 tospec workflow',
118
+ 'control.theme': '主題',
119
+ 'control.language': '語言',
120
+ 'control.refresh': '↻ 重新讀取',
121
+ 'range.30': '最近 30 天',
122
+ 'range.90': '最近 90 天',
123
+ 'range.365': '最近一年',
124
+ 'range.all': '全部時間',
125
+ 'theme.system': '跟隨系統',
126
+ 'theme.light': '淺色',
127
+ 'theme.dark': '深色',
128
+ 'theme.dracula': 'Dracula',
129
+ 'chart.totals.title': '各 Skill 的總投入時間',
130
+ 'chart.totals.note': '實色為實際工作時間, 凹陷部分為閒置 — 也就是一次執行之中的停頓.',
131
+ 'chart.totals.axis': '分鐘 — 依實際工作時間排序',
132
+ 'chart.median.title': '典型的單次時長',
133
+ 'chart.median.note':
134
+ '每個 Skill 的中位數. 一個 Skill 可能在上面的總量排第一, 在這裡卻很低 — 那代表次數多而不是每次慢.',
135
+ 'chart.median.axis': '每次執行的中位數分鐘 — 依實際工作時間排序',
136
+ 'chart.distribution.title': '每次執行的分佈',
137
+ 'chart.distribution.note':
138
+ '每一個點是一次執行. 長條從最短跨到最長, 實色部分是實際工作時間的範圍.',
139
+ 'chart.distribution.axis': '每次執行的分鐘數',
140
+ 'chart.series.title.day': '每日投入時間',
141
+ 'chart.series.title.week': '每週投入時間',
142
+ 'chart.series.title.month': '每月投入時間',
143
+ 'chart.series.note.day': '依本地時間計算. 一次執行整筆算在它開始的那一天.',
144
+ 'chart.series.note.week': '依本地時間計算. 一次執行整筆算在它開始的那一週.',
145
+ 'chart.series.note.month': '依本地時間計算. 一次執行整筆算在它開始的那個月.',
146
+ 'chart.series.axis': '實際工作分鐘數',
147
+ 'chart.timeline.title': '執行時間軸',
148
+ 'chart.timeline.note':
149
+ '一次執行一個圓, 面積代表實際工作時間. 重疊的執行會被錯開, 而且每次錯開的位置都相同.',
150
+ 'series.engaged': '實際工作',
151
+ 'series.idle': '閒置',
152
+ 'series.medianSpan': '總時長中位數',
153
+ 'series.medianEngaged': '實際工作中位數',
154
+ 'series.spanRange': '總時長範圍',
155
+ 'series.engagedRange': '實際工作範圍',
156
+ 'series.eachRun': '每次執行',
157
+ 'empty.noHistory.title': '這個專案沒有歷史紀錄',
158
+ 'empty.noHistory.body': '{tool} — 找過的位置: {dir}',
159
+ 'empty.noAttribution.title': '沒有任何 Skill 執行紀錄',
160
+ 'empty.noAttribution.body': '掃描了 {sessions} 個 session, 沒有任何一個帶有 Skill 歸屬資訊.',
161
+ 'empty.noAttribution.hint':
162
+ 'Skill 歸屬資訊只存在於較新的 session, 舊的紀錄無法回溯統計.',
163
+ 'empty.noRange.title': '這個時間範圍內沒有資料',
164
+ 'empty.noRange.body': 'Transcript 裡有 {sessions} 個帶歸屬資訊的 session, 只是都不在這個範圍內.',
165
+ 'empty.allHidden.title': '全部都被隱藏了',
166
+ 'empty.allHidden.body': '這個範圍內沒有 tospec workflow 執行過. 取消勾選「只看 tospec workflow」即可看到其餘的.',
167
+ 'error.title': '無法載入報表',
168
+ 'footer.source': '{tool}: 取自 {attributed}/{sessions} 個帶有 Skill 歸屬資訊的 session{span}.',
169
+ 'footer.noSource': '{tool}: 這個專案沒有找到歷史紀錄.',
170
+ 'footer.codexCaveat': '這裡的 Skill 歸屬資訊是從 SKILL.md 讀取行為推論出來的, 並非直接記錄.',
171
+ 'footer.sourceSpan': ', {first} 至 {last}',
172
+ 'footer.definition':
173
+ '一次執行是指單一 Skill 連續進行的一段, 中間沒有被其他 Skill 打斷, 也沒有超過切分間隔 ({split} 分) 的停頓. 閒置則是一次執行之中, 停頓超過閒置間隔 ({idle} 分) 的那些時間.',
174
+ },
175
+ };
176
+
177
+ const LOCALES = Object.keys(STRINGS);
178
+
179
+ /** Looks up a string and fills `{name}` placeholders. Falls back to English. */
180
+ function t(key, vars) {
181
+ const table = STRINGS[controls.language] || STRINGS.en;
182
+ const raw = table[key] ?? STRINGS.en[key] ?? key;
183
+ if (!vars) return raw;
184
+ return raw.replace(/\{(\w+)\}/g, (_, name) => (name in vars ? String(vars[name]) : `{${name}}`));
185
+ }
186
+
187
+ // --- tiny helpers ----------------------------------------------------------
188
+
189
+ async function api(path) {
190
+ const res = await fetch(path);
191
+ if (!res.ok) {
192
+ let detail = 'HTTP ' + res.status;
193
+ try {
194
+ const body = await res.json();
195
+ if (body && body.error) detail = body.error;
196
+ } catch {
197
+ // no JSON body; keep the status line
198
+ }
199
+ throw new Error(detail);
200
+ }
201
+ return res.json();
202
+ }
203
+
204
+ /** Minimal DOM builder; all text goes in as textContent (XSS-safe). */
205
+ function el(tag, props = {}, children = []) {
206
+ const node = document.createElement(tag);
207
+ for (const [k, v] of Object.entries(props)) {
208
+ if (v == null) continue;
209
+ if (k === 'class') node.className = v;
210
+ else if (k === 'text') node.textContent = v;
211
+ else node.setAttribute(k, v);
212
+ }
213
+ for (const child of [].concat(children)) {
214
+ if (child == null) continue;
215
+ node.append(child.nodeType ? child : document.createTextNode(String(child)));
216
+ }
217
+ return node;
218
+ }
219
+
220
+ const minutes = (ms) => ms / MS_PER_MINUTE;
221
+
222
+ /** The agent a row's figures came from, as it appears in that row's label. */
223
+ const SOURCE_LABEL = { 'claude-code': 'Claude', codex: 'Codex' };
224
+
225
+ /**
226
+ * Skills that are not tospec's own workflows keep the marker the table used.
227
+ * Colour alone must never be what tells them apart.
228
+ *
229
+ * Every row also names its agent, including Claude Code's. Marking only Codex
230
+ * left the other source implicit, so an unsuffixed row read as "the skill"
231
+ * rather than "the skill, as measured by one of two agents" — and the two are
232
+ * never summed, which only makes sense once both are visibly one agent's
233
+ * figures. Naming both also keeps the distinction textual rather than a colour
234
+ * difference, the same convention the non-workflow marker follows.
235
+ */
236
+ function skillLabel(s) {
237
+ const base = s.workflow ? s.skill : s.skill + ' *';
238
+ return base + ' (' + (SOURCE_LABEL[s.source] || s.source) + ')';
239
+ }
240
+
241
+ /**
242
+ * The composite key identifying one report row: a skill under one source.
243
+ * Mirrors `skillRowKey` in src/core/skill-metrics.ts exactly — this file
244
+ * cannot import that TS module, so the format is reproduced here rather than
245
+ * shared.
246
+ */
247
+ function rowKey(skill, source) {
248
+ return skill + '::' + source;
249
+ }
250
+
251
+ // --- charts ----------------------------------------------------------------
252
+
253
+ const hasChart = typeof Chart !== 'undefined';
254
+ const liveCharts = new Set();
255
+
256
+ // Axis ticks, legends, and axis titles are drawn onto the canvas by Chart.js and
257
+ // never see a stylesheet, so the page's type scale has to be applied here too —
258
+ // otherwise the text covering most of the page stays at Chart.js's 12px default
259
+ // while everything around it grows.
260
+ if (hasChart) Chart.defaults.font.size = 14;
261
+
262
+ function cssVar(name) {
263
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
264
+ }
265
+
266
+ function newChart(canvas, config) {
267
+ const chart = new Chart(canvas, config);
268
+ liveCharts.add(chart);
269
+ return chart;
270
+ }
271
+
272
+ function chartTheme() {
273
+ return {
274
+ muted: cssVar('--muted'),
275
+ grid: cssVar('--border'),
276
+ engaged: cssVar('--engaged'),
277
+ idle: cssVar('--idle'),
278
+ point: cssVar('--point'),
279
+ pointSoft: cssVar('--point-soft'),
280
+ ramp: ['--ramp-1', '--ramp-2', '--ramp-3', '--ramp-4', '--ramp-5', '--ramp-6'].map(cssVar),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Total time per skill: engaged solid, idle recessed, so the whole bar is the
286
+ * wall-clock span.
287
+ *
288
+ * Rows arrive ordered by engaged total and are drawn in that order. Because the
289
+ * bar's full length is span, those totals are not monotonic — a skill can sort
290
+ * last and still have the longest bar. The recessed idle segment is what keeps
291
+ * that readable: the solid segments' end points form a descending staircase,
292
+ * and the axis title says that staircase is the sort key.
293
+ */
294
+ function buildTotalsChart(canvas, skills) {
295
+ const theme = chartTheme();
296
+ return newChart(canvas, {
297
+ type: 'bar',
298
+ data: {
299
+ labels: skills.map(skillLabel),
300
+ datasets: [
301
+ {
302
+ label: t('series.engaged'),
303
+ data: skills.map((s) => minutes(s.engaged.totalMs)),
304
+ backgroundColor: theme.engaged,
305
+ },
306
+ {
307
+ label: t('series.idle'),
308
+ data: skills.map((s) => minutes(s.span.totalMs - s.engaged.totalMs)),
309
+ backgroundColor: theme.idle,
310
+ },
311
+ ],
312
+ },
313
+ options: {
314
+ indexAxis: 'y',
315
+ responsive: true,
316
+ maintainAspectRatio: false,
317
+ scales: {
318
+ x: {
319
+ stacked: true,
320
+ beginAtZero: true,
321
+ title: { display: true, text: t('chart.totals.axis'), color: theme.muted },
322
+ ticks: { color: theme.muted },
323
+ grid: { color: theme.grid },
324
+ },
325
+ y: {
326
+ stacked: true,
327
+ ticks: { color: theme.muted },
328
+ grid: { display: false },
329
+ },
330
+ },
331
+ plugins: {
332
+ legend: { labels: { color: theme.muted } },
333
+ },
334
+ },
335
+ });
336
+ }
337
+
338
+ /**
339
+ * The middle run of each skill, span and engaged side by side.
340
+ *
341
+ * Deliberately **not** stacked. Stacking would present
342
+ * `median(span) - median(engaged)` as a median idle time, and it is not one —
343
+ * the difference of two medians is not the median of the differences. The bars
344
+ * never invert (the k-th smallest engaged time is at most the k-th smallest
345
+ * span), but that is a fact about their lengths, not a licence to subtract them.
346
+ *
347
+ * Ordered by engaged total like the chart above, so the two read as the same
348
+ * list twice — which is what makes "high total, low median" visible as a shape
349
+ * rather than something to work out.
350
+ */
351
+ function buildMedianChart(canvas, skills) {
352
+ const theme = chartTheme();
353
+ return newChart(canvas, {
354
+ type: 'bar',
355
+ data: {
356
+ labels: skills.map(skillLabel),
357
+ datasets: [
358
+ {
359
+ label: t('series.medianEngaged'),
360
+ data: skills.map((s) => minutes(s.engaged.medianMs)),
361
+ backgroundColor: theme.engaged,
362
+ },
363
+ {
364
+ label: t('series.medianSpan'),
365
+ data: skills.map((s) => minutes(s.span.medianMs)),
366
+ backgroundColor: theme.idle,
367
+ },
368
+ ],
369
+ },
370
+ options: {
371
+ indexAxis: 'y',
372
+ responsive: true,
373
+ maintainAspectRatio: false,
374
+ scales: {
375
+ x: {
376
+ beginAtZero: true,
377
+ title: { display: true, text: t('chart.median.axis'), color: theme.muted },
378
+ ticks: { color: theme.muted },
379
+ grid: { color: theme.grid },
380
+ },
381
+ y: { ticks: { color: theme.muted }, grid: { display: false } },
382
+ },
383
+ plugins: { legend: { labels: { color: theme.muted } } },
384
+ },
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Per-run distribution: one row per skill, the span range as a recessed
390
+ * backdrop, the engaged range solid over it, and every individual run as a dot.
391
+ *
392
+ * The two ranges share a row rather than getting one each. Every run's engaged
393
+ * time is at most its span, so a skill's engaged range is contained by its span
394
+ * range at both ends and neither can occlude the other; where a skill's runs all
395
+ * have equal span and engaged time the two coincide and read as a single bar,
396
+ * which is honest — they are the same numbers.
397
+ *
398
+ * The dots are what carry the shape a median and a maximum discard, and they are
399
+ * also what keeps a zero-length run visible: its range bar has no width, but its
400
+ * dot sits at zero like any other.
401
+ */
402
+ function buildDistributionChart(canvas, skills, runs) {
403
+ const theme = chartTheme();
404
+ const labels = skills.map(skillLabel);
405
+ // Keyed by skill-and-source, not skill alone: a skill with runs from both
406
+ // sources gets two rows, and a run must land on its own source's row only.
407
+ const rowOf = new Map(skills.map((s, i) => [rowKey(s.skill, s.source), i]));
408
+
409
+ const range = (stats) => [minutes(stats.minMs), minutes(stats.maxMs)];
410
+
411
+ return newChart(canvas, {
412
+ type: 'bar',
413
+ data: {
414
+ labels,
415
+ datasets: [
416
+ {
417
+ label: t('series.spanRange'),
418
+ data: skills.map((s) => range(s.span)),
419
+ backgroundColor: theme.idle,
420
+ // Overlaid, not placed side by side: both bars describe the same row.
421
+ grouped: false,
422
+ barPercentage: 0.8,
423
+ },
424
+ {
425
+ label: t('series.engagedRange'),
426
+ data: skills.map((s) => range(s.engaged)),
427
+ backgroundColor: theme.engaged,
428
+ grouped: false,
429
+ barPercentage: 0.45,
430
+ },
431
+ {
432
+ type: 'scatter',
433
+ label: t('series.eachRun'),
434
+ data: runs
435
+ .filter((r) => rowOf.has(rowKey(r.skill, r.source)))
436
+ .map((r) => ({ x: minutes(r.engagedMs), y: labels[rowOf.get(rowKey(r.skill, r.source))] })),
437
+ backgroundColor: theme.point,
438
+ pointRadius: 3,
439
+ },
440
+ ],
441
+ },
442
+ options: {
443
+ indexAxis: 'y',
444
+ responsive: true,
445
+ maintainAspectRatio: false,
446
+ scales: {
447
+ x: {
448
+ beginAtZero: true,
449
+ title: { display: true, text: t('chart.distribution.axis'), color: theme.muted },
450
+ ticks: { color: theme.muted },
451
+ grid: { color: theme.grid },
452
+ },
453
+ y: { ticks: { color: theme.muted }, grid: { display: false } },
454
+ },
455
+ plugins: { legend: { labels: { color: theme.muted } } },
456
+ },
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Engaged time per period, stacked by skill. Buckets, their local dates, and the
462
+ * granularity all arrive decided — the page only draws them.
463
+ */
464
+ function buildSeriesChart(canvas, series, skills) {
465
+ const theme = chartTheme();
466
+ // A stacked series needs a colour per skill rather than the two-token pair the
467
+ // other charts use, so it walks a fixed ramp. Order matches the ranking, so
468
+ // the largest contributors keep the strongest colours.
469
+ const ramp = theme.ramp;
470
+ return newChart(canvas, {
471
+ type: 'bar',
472
+ data: {
473
+ labels: series.buckets.map((b) => b.key),
474
+ datasets: skills.map((s, i) => ({
475
+ label: skillLabel(s),
476
+ // Keyed by skill-and-source: a bucket keeps the two sources' engaged
477
+ // time for the same skill apart rather than summing them.
478
+ data: series.buckets.map((b) => minutes(b.byRow[rowKey(s.skill, s.source)] || 0)),
479
+ backgroundColor: ramp[i % ramp.length],
480
+ })),
481
+ },
482
+ options: {
483
+ responsive: true,
484
+ maintainAspectRatio: false,
485
+ interaction: { mode: 'index', intersect: false },
486
+ scales: {
487
+ x: { stacked: true, ticks: { color: theme.muted, maxRotation: 0, autoSkip: true }, grid: { display: false } },
488
+ y: {
489
+ stacked: true,
490
+ beginAtZero: true,
491
+ title: { display: true, text: t('chart.series.axis'), color: theme.muted },
492
+ ticks: { color: theme.muted },
493
+ grid: { color: theme.grid },
494
+ },
495
+ },
496
+ plugins: { legend: { labels: { color: theme.muted, boxWidth: 12 } } },
497
+ },
498
+ });
499
+ }
500
+
501
+ /**
502
+ * Stable offset in [-0.28, 0.28] for one run, derived from its session and index.
503
+ *
504
+ * Never random: switching themes rebuilds every chart, because Chart.js reads
505
+ * CSS variables when a chart is constructed. A random offset would put every
506
+ * point somewhere new on rebuild, which reads as the data having changed.
507
+ */
508
+ function stableJitter(run, index) {
509
+ const seed = run.session + ':' + index;
510
+ let h = 5381;
511
+ for (let i = 0; i < seed.length; i++) h = ((h << 5) + h + seed.charCodeAt(i)) | 0;
512
+ return ((Math.abs(h) % 1000) / 1000 - 0.5) * 0.56;
513
+ }
514
+
515
+ /** Follows the selected language, so an axis never labels itself in another one. */
516
+ const dayLabel = (ms) =>
517
+ new Date(ms).toLocaleDateString(controls.language, { month: 'short', day: 'numeric' });
518
+
519
+ /**
520
+ * Every run on a time axis, one row per skill. Bubble area — not radius —
521
+ * carries engaged time, which is the mapping the eye actually reads.
522
+ *
523
+ * A `linear` x scale over raw milliseconds, not Chart.js's `time` scale: that
524
+ * one needs a date adapter this page does not ship, and tick formatting is all
525
+ * this needs from it.
526
+ *
527
+ * No cap on how many runs are drawn. Overlap is handled by transparency and the
528
+ * offset above; the range control is the lever if a history ever gets too dense.
529
+ */
530
+ function buildTimelineChart(canvas, skills, runs) {
531
+ const theme = chartTheme();
532
+ const labels = skills.map(skillLabel);
533
+ const rowOf = new Map(skills.map((s, i) => [rowKey(s.skill, s.source), i]));
534
+ const peak = Math.max(1, ...runs.map((r) => r.engagedMs));
535
+
536
+ return newChart(canvas, {
537
+ type: 'bubble',
538
+ data: {
539
+ datasets: [
540
+ {
541
+ label: t('series.eachRun'),
542
+ data: runs
543
+ .filter((r) => rowOf.has(rowKey(r.skill, r.source)))
544
+ .map((r, i) => ({
545
+ x: r.startMs,
546
+ y: rowOf.get(rowKey(r.skill, r.source)) + stableJitter(r, i),
547
+ // Area proportional to engaged time, so radius follows its root.
548
+ r: 3 + 9 * Math.sqrt(r.engagedMs / peak),
549
+ skill: r.skill,
550
+ source: r.source,
551
+ session: r.session,
552
+ })),
553
+ backgroundColor: theme.pointSoft,
554
+ },
555
+ ],
556
+ },
557
+ options: {
558
+ responsive: true,
559
+ maintainAspectRatio: false,
560
+ scales: {
561
+ x: {
562
+ type: 'linear',
563
+ ticks: { color: theme.muted, maxRotation: 0, callback: (v) => dayLabel(v) },
564
+ grid: { color: theme.grid },
565
+ },
566
+ y: {
567
+ type: 'category',
568
+ labels,
569
+ offset: true,
570
+ ticks: { color: theme.muted },
571
+ grid: { display: false },
572
+ },
573
+ },
574
+ plugins: { legend: { display: false } },
575
+ },
576
+ });
577
+ }
578
+
579
+ // --- render ----------------------------------------------------------------
580
+
581
+ // A chart with `maintainAspectRatio: false` fills whatever height its container
582
+ // has, so a fixed container squeezes every row thinner as skills accumulate.
583
+ // Four of the five charts put one skill per row, so their height has to be
584
+ // computed from the row count and the page allowed to scroll — which is the
585
+ // trade already chosen over capping how many skills are shown.
586
+
587
+ /** One skill row: enough for a 14px label plus breathing room. */
588
+ const ROW_PX = 32;
589
+ /** Bubbles are offset vertically and can reach 24px across, so rows need more. */
590
+ const TIMELINE_ROW_PX = 44;
591
+ /** Axis labels, legend, and axis title, which do not grow with the rows. */
592
+ const CHART_CHROME_PX = 78;
593
+ const MIN_CHART_PX = 220;
594
+
595
+ function heightForRows(rows, rowPx = ROW_PX) {
596
+ return Math.max(MIN_CHART_PX, CHART_CHROME_PX + rows * rowPx);
597
+ }
598
+
599
+ /**
600
+ * `height` is the container's pixel height. Charts whose y axis is a skill pass
601
+ * one derived from the row count; the per-period series does not — its y axis is
602
+ * minutes, so more skills only add segments to bars that already have room.
603
+ */
604
+ function chartPanel(title, note, height) {
605
+ const canvas = el('canvas');
606
+ const wrap = el('div', { class: 'chart' }, canvas);
607
+ wrap.style.height = height + 'px';
608
+ const panel = el('section', { class: 'panel' }, [
609
+ el('h2', { text: title }),
610
+ note ? el('p', { class: 'panel-note', text: note }) : null,
611
+ wrap,
612
+ ]);
613
+ return { panel, canvas };
614
+ }
615
+
616
+ // --- controls --------------------------------------------------------------
617
+
618
+ /**
619
+ * Every control here changes what is computed, so each one is a request. The
620
+ * range is a browsing choice; the two gaps re-bound what a run even is. None of
621
+ * them is applied in this file — the page holds no statistics.
622
+ */
623
+ const controls = {
624
+ rangeDays: 'all', // '30' | '90' | '365' | 'all' — all-time is what the page opens on
625
+ idleGapMs: 5 * 60000,
626
+ splitGapMs: 30 * 60000,
627
+ /** View-only: hides rows, changes nothing that was computed. */
628
+ workflowsOnly: false,
629
+ theme: 'system', // system | light | dark | dracula
630
+ /** View-only: wording changes nothing that was computed. */
631
+ language: 'en', // en | zh-TW
632
+ ...loadPrefs(),
633
+ };
634
+
635
+ function applyLanguage(locale) {
636
+ document.documentElement.setAttribute('lang', locale);
637
+ }
638
+
639
+ applyLanguage(controls.language);
640
+
641
+ function setControl(key, value, recompute) {
642
+ controls[key] = value;
643
+ savePrefs(controls);
644
+ if (recompute) void load();
645
+ else render();
646
+ }
647
+
648
+ // --- theme -----------------------------------------------------------------
649
+
650
+ const systemDark =
651
+ typeof window !== 'undefined' && window.matchMedia
652
+ ? window.matchMedia('(prefers-color-scheme: dark)')
653
+ : { matches: false, addEventListener() {} };
654
+
655
+ /** 'system' resolves against the OS; the rest map straight to a data-theme value. */
656
+ function applyTheme(choice) {
657
+ const resolved = choice === 'system' ? (systemDark.matches ? 'dark' : 'light') : choice;
658
+ document.documentElement.setAttribute('data-theme', resolved);
659
+ }
660
+
661
+ applyTheme(controls.theme);
662
+ systemDark.addEventListener('change', () => {
663
+ if (controls.theme === 'system') {
664
+ applyTheme('system');
665
+ render(); // charts read CSS variables when constructed, so they must be rebuilt
666
+ }
667
+ });
668
+
669
+ function reportQuery(refresh) {
670
+ const params = new URLSearchParams({
671
+ idleGap: String(controls.idleGapMs),
672
+ splitGap: String(controls.splitGapMs),
673
+ });
674
+ if (controls.rangeDays !== 'all') {
675
+ params.set('since', String(Date.now() - Number(controls.rangeDays) * 86400000));
676
+ }
677
+ if (refresh) params.set('refresh', '1');
678
+ return params;
679
+ }
680
+
681
+ function labelled(text, input) {
682
+ return el('label', { class: 'control' }, [el('span', { text }), input]);
683
+ }
684
+
685
+ function select(options, value, onChange) {
686
+ const node = el('select');
687
+ for (const [v, text] of options) {
688
+ const option = el('option', { value: v, text });
689
+ if (v === value) option.setAttribute('selected', 'selected');
690
+ node.append(option);
691
+ }
692
+ node.value = value;
693
+ node.addEventListener('change', () => onChange(node.value));
694
+ return node;
695
+ }
696
+
697
+ function minutesInput(valueMs, onChange) {
698
+ const node = el('input', { type: 'number', min: '1', step: '1', class: 'gap' });
699
+ node.value = String(minutes(valueMs));
700
+ node.addEventListener('change', () => {
701
+ const n = Number(node.value);
702
+ // The server refuses non-positive values; not sending them keeps the page
703
+ // from flashing an error the user cannot act on.
704
+ if (Number.isFinite(n) && n > 0) onChange(n * MS_PER_MINUTE);
705
+ else node.value = String(minutes(valueMs));
706
+ });
707
+ return node;
708
+ }
709
+
710
+ function checkbox(text, checked, onChange) {
711
+ const input = el('input', { type: 'checkbox' });
712
+ input.checked = checked;
713
+ input.addEventListener('change', () => onChange(input.checked));
714
+ return el('label', { class: 'control control-inline' }, [input, el('span', { text })]);
715
+ }
716
+
717
+ function renderControls() {
718
+ return el('section', { class: 'controls' }, [
719
+ labelled(
720
+ t('control.range'),
721
+ select(
722
+ [
723
+ ['30', t('range.30')],
724
+ ['90', t('range.90')],
725
+ ['365', t('range.365')],
726
+ ['all', t('range.all')],
727
+ ],
728
+ controls.rangeDays,
729
+ (v) => setControl('rangeDays', v, true)
730
+ )
731
+ ),
732
+ labelled(
733
+ t('control.idleGap'),
734
+ minutesInput(controls.idleGapMs, (ms) => setControl('idleGapMs', ms, true))
735
+ ),
736
+ labelled(
737
+ t('control.splitGap'),
738
+ minutesInput(controls.splitGapMs, (ms) => setControl('splitGapMs', ms, true))
739
+ ),
740
+ // View-only, so no request: each skill's statistics are computed
741
+ // independently of every other skill's, and hiding a row cannot alter them.
742
+ checkbox(t('control.workflowsOnly'), controls.workflowsOnly, (on) =>
743
+ setControl('workflowsOnly', on, false)
744
+ ),
745
+ // Everything from here sits at the right: what the page shows, rather than
746
+ // what it computes.
747
+ el('div', { class: 'controls-right' }, [
748
+ labelled(
749
+ t('control.theme'),
750
+ select(
751
+ [
752
+ ['system', t('theme.system')],
753
+ ['light', t('theme.light')],
754
+ ['dark', t('theme.dark')],
755
+ ['dracula', t('theme.dracula')],
756
+ ],
757
+ controls.theme,
758
+ (v) => {
759
+ applyTheme(v);
760
+ setControl('theme', v, false);
761
+ }
762
+ )
763
+ ),
764
+ // View-only like the theme: wording changes nothing that was computed.
765
+ labelled(
766
+ t('control.language'),
767
+ select(
768
+ LOCALES.map((code) => [code, STRINGS[code]['lang.name']]),
769
+ controls.language,
770
+ (v) => {
771
+ applyLanguage(v);
772
+ setControl('language', v, false);
773
+ }
774
+ )
775
+ ),
776
+ // The report measures the session viewing it, so what is on screen goes
777
+ // stale while it is being read. This is the only way to pick that up: every
778
+ // other control recomputes from the parse taken when the server started.
779
+ refreshButton(),
780
+ ]),
781
+ ]);
782
+ }
783
+
784
+ function refreshButton() {
785
+ const button = el('button', { type: 'button', class: 'refresh', text: t('control.refresh') });
786
+ button.addEventListener('click', () => void load(true));
787
+ return button;
788
+ }
789
+
790
+ /**
791
+ * Applies the view-only controls. Nothing here recomputes: hiding a skill's row
792
+ * cannot change any other skill's numbers, because each skill's statistics are
793
+ * derived from its own runs alone.
794
+ */
795
+ function visible(report) {
796
+ if (!controls.workflowsOnly) return report;
797
+ const skills = report.skills.filter((s) => s.workflow);
798
+ const kept = new Set(skills.map((s) => s.skill));
799
+ return { ...report, skills, runs: report.runs.filter((r) => kept.has(r.skill)) };
800
+ }
801
+
802
+ // --- empty states and footer -----------------------------------------------
803
+
804
+ function note(title, ...lines) {
805
+ return el('section', { class: 'panel' }, [
806
+ el('h2', { text: title }),
807
+ ...lines.filter(Boolean).map((text) => el('p', { class: 'panel-note', text })),
808
+ ]);
809
+ }
810
+
811
+ /**
812
+ * Why a report is empty, or null when it is not.
813
+ *
814
+ * The three outcomes are genuinely different and the old table conflated the
815
+ * last two: no history at all, history that predates skill attribution, and a
816
+ * range that happens to hold nothing. Saying "no history" for the third would
817
+ * send someone looking for a missing directory that is right there.
818
+ *
819
+ * Claude Code and Codex are scanned independently, so these three outcomes are
820
+ * evaluated across both combined — one source having history says nothing
821
+ * about the other (which source is missing, if any, is what the per-source
822
+ * footer line says instead; this state only fires when *neither* has anything
823
+ * to show).
824
+ */
825
+ function emptyState(full, shown) {
826
+ const { claudeCode, codex } = full.sources;
827
+ if (!claudeCode.available && !codex.available) {
828
+ return note(
829
+ t('empty.noHistory.title'),
830
+ t('empty.noHistory.body', { tool: 'Claude Code', dir: claudeCode.transcriptDir }),
831
+ t('empty.noHistory.body', { tool: 'Codex', dir: codex.transcriptDir })
832
+ );
833
+ }
834
+ const attributedSessions = claudeCode.attributedSessions + codex.attributedSessions;
835
+ const sessions = claudeCode.sessions + codex.sessions;
836
+ if (attributedSessions === 0) {
837
+ return note(
838
+ t('empty.noAttribution.title'),
839
+ t('empty.noAttribution.body', { sessions }),
840
+ t('empty.noAttribution.hint')
841
+ );
842
+ }
843
+ if (full.skills.length === 0) {
844
+ return note(t('empty.noRange.title'), t('empty.noRange.body', { sessions: attributedSessions }));
845
+ }
846
+ if (shown.skills.length === 0) {
847
+ return note(t('empty.allHidden.title'), t('empty.allHidden.body'));
848
+ }
849
+ return null;
850
+ }
851
+
852
+ /**
853
+ * One source's line: its usual stats sentence, or — when this source has
854
+ * nothing for this project — a plain statement of that fact rather than
855
+ * silently rendering as if the source did not exist (codex-metrics-source
856
+ * spec, "A project with no matching Codex sessions..."). Codex's line always
857
+ * carries the heuristic-attribution caveat, wherever Codex figures are shown.
858
+ */
859
+ function sourceFooterLine(tool, source) {
860
+ if (!source.available || source.sessions === 0) {
861
+ return t('footer.noSource', { tool });
862
+ }
863
+ const span =
864
+ source.firstSeen && source.lastSeen
865
+ ? t('footer.sourceSpan', {
866
+ first: source.firstSeen.slice(0, 10),
867
+ last: source.lastSeen.slice(0, 10),
868
+ })
869
+ : '';
870
+ const base = t('footer.source', {
871
+ tool,
872
+ attributed: source.attributedSessions,
873
+ sessions: source.sessions,
874
+ span,
875
+ });
876
+ return tool === 'Codex' ? base + ' ' + t('footer.codexCaveat') : base;
877
+ }
878
+
879
+ function footer(report) {
880
+ const { claudeCode, codex } = report.sources;
881
+ return el('section', { class: 'footer' }, [
882
+ el('p', { text: sourceFooterLine('Claude Code', claudeCode) }),
883
+ el('p', { text: sourceFooterLine('Codex', codex) }),
884
+ // Without this, one skill showing up as several runs looks like a bug rather
885
+ // than the split gap doing its job. The current values, not fixed ones —
886
+ // both are adjustable, so a hard-coded "30 min" would start lying.
887
+ el('p', {
888
+ text: t('footer.definition', {
889
+ split: minutes(report.thresholds.splitGapMs),
890
+ idle: minutes(report.thresholds.idleGapMs),
891
+ }),
892
+ }),
893
+ ]);
894
+ }
895
+
896
+ function renderReport(full) {
897
+ const report = visible(full);
898
+ view.replaceChildren();
899
+ view.append(renderControls());
900
+
901
+ const empty = emptyState(full, report);
902
+ if (empty) {
903
+ view.append(empty);
904
+ if (full.sources.claudeCode.available || full.sources.codex.available) view.append(footer(full));
905
+ return;
906
+ }
907
+
908
+ const rows = report.skills.length;
909
+
910
+ const totals = chartPanel(
911
+ t('chart.totals.title'),
912
+ t('chart.totals.note'),
913
+ heightForRows(rows)
914
+ );
915
+ view.append(totals.panel);
916
+ if (hasChart) buildTotalsChart(totals.canvas, report.skills);
917
+
918
+ // Straight after the totals, because "how much in all" and "how long each
919
+ // time" are the pair that explain each other.
920
+ const median = chartPanel(
921
+ t('chart.median.title'),
922
+ t('chart.median.note'),
923
+ // Two bars per skill rather than one stacked bar, so each row needs more.
924
+ heightForRows(rows, ROW_PX * 1.5)
925
+ );
926
+ view.append(median.panel);
927
+ if (hasChart) buildMedianChart(median.canvas, report.skills);
928
+
929
+ const distribution = chartPanel(
930
+ t('chart.distribution.title'),
931
+ t('chart.distribution.note'),
932
+ heightForRows(rows)
933
+ );
934
+ view.append(distribution.panel);
935
+ if (hasChart) buildDistributionChart(distribution.canvas, report.skills, report.runs);
936
+
937
+ const period = report.series.granularity || 'day';
938
+ const series = chartPanel(
939
+ t('chart.series.title.' + period),
940
+ t('chart.series.note.' + period),
941
+ // Its y axis is minutes, so more skills only add segments to bars that
942
+ // already have room — this one does not grow.
943
+ 320
944
+ );
945
+ view.append(series.panel);
946
+ if (hasChart) buildSeriesChart(series.canvas, report.series, report.skills);
947
+
948
+ const timeline = chartPanel(
949
+ t('chart.timeline.title'),
950
+ t('chart.timeline.note'),
951
+ heightForRows(rows, TIMELINE_ROW_PX)
952
+ );
953
+ view.append(timeline.panel);
954
+ if (hasChart) buildTimelineChart(timeline.canvas, report.skills, report.runs);
955
+
956
+ view.append(footer(full));
957
+ }
958
+
959
+ /** A failed load must say so; a blank page explains nothing. */
960
+ function renderError(err) {
961
+ view.replaceChildren();
962
+ view.append(
963
+ el('section', { class: 'panel' }, [
964
+ el('h2', { text: t('error.title') }),
965
+ el('p', { class: 'panel-note', text: String((err && err.message) || err) }),
966
+ ])
967
+ );
968
+ }
969
+
970
+ /** Charts read CSS variables when constructed, so a rebuild must drop the old ones. */
971
+ function destroyCharts() {
972
+ for (const chart of liveCharts) chart.destroy();
973
+ liveCharts.clear();
974
+ }
975
+
976
+ /** The last report fetched, so view-only controls can redraw without a request. */
977
+ let current = null;
978
+
979
+ function render() {
980
+ if (!current) return;
981
+ destroyCharts();
982
+ renderReport(current);
983
+ }
984
+
985
+ function load(refresh) {
986
+ destroyCharts();
987
+ return api('/api/report?' + reportQuery(refresh))
988
+ .then((report) => {
989
+ current = report;
990
+ renderReport(report);
991
+ })
992
+ .catch(renderError);
993
+ }
994
+
995
+ void load();