syndes 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/analytics/index.mjs +52 -16
- package/analytics/metrics/time.mjs +40 -3
- package/analytics/metrics/window.mjs +94 -0
- package/analytics/rollup.mjs +18 -3
- package/analytics/team.mjs +69 -25
- package/bin/cli.mjs +13 -3
- package/collect/transcript.mjs +1 -1
- package/dashboard/api/index.mjs +11 -0
- package/dashboard/auth.mjs +35 -0
- package/dashboard/router.mjs +1 -0
- package/dashboard/server.mjs +42 -6
- package/dashboard/web/api.js +1 -0
- package/dashboard/web/app.css +79 -0
- package/dashboard/web/app.js +128 -27
- package/dashboard/web/charts.js +113 -16
- package/dashboard/web/ui.js +121 -5
- package/dashboard/web/views/overview.js +164 -27
- package/dashboard/web/views/settings.js +1 -1
- package/dashboard/web/views/team.js +138 -75
- package/package.json +1 -2
- package/practices/catalog.mjs +1 -1
- package/runtime/config.mjs +1 -1
- package/runtime/worker.mjs +1 -1
- package/src/report.mjs +8 -6
- package/src/term.mjs +14 -2
- package/analytics/metrics/cost.mjs +0 -83
package/dashboard/web/ui.js
CHANGED
|
@@ -45,6 +45,8 @@ const ICONS = {
|
|
|
45
45
|
'M15 8a2 2 0 1 0 4 0 2 2 0 0 0-4 0', 'M8 16a2 2 0 1 0 4 0 2 2 0 0 0-4 0',
|
|
46
46
|
],
|
|
47
47
|
chevron: ['M6.5 9.5 12 15l5.5-5.5'],
|
|
48
|
+
left: ['M14.5 5.5 8 12l6.5 6.5'],
|
|
49
|
+
right: ['M9.5 5.5 16 12l-6.5 6.5'],
|
|
48
50
|
tick: ['M5 12.5 9.5 17 19 7.5'],
|
|
49
51
|
};
|
|
50
52
|
|
|
@@ -188,6 +190,77 @@ export function dropdown({ value, options, onChange, label = null, onCard = fals
|
|
|
188
190
|
return trigger;
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The day picker: step back a day, pick one from a list, step forward.
|
|
195
|
+
*
|
|
196
|
+
* Two ways in, because they answer different questions. The arrows are for
|
|
197
|
+
* "what about the day before this one", which is the move somebody makes five
|
|
198
|
+
* times in a row and must not cost five menu openings. The list is for "take me
|
|
199
|
+
* to last Tuesday", and it carries each day's volume so a quiet day is visible
|
|
200
|
+
* before it is opened rather than after.
|
|
201
|
+
*
|
|
202
|
+
* Forward is disabled on today rather than hidden. A control that disappears
|
|
203
|
+
* makes the row reflow under the pointer, and the disabled state is what says
|
|
204
|
+
* "this is the edge" instead of leaving someone to wonder where the button went.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} day the selected day, YYYY-MM-DD
|
|
207
|
+
* @param {object[]} days [{ day, records, activeMs, tokens }], newest first
|
|
208
|
+
* @param {Function} onChange called with the newly selected day
|
|
209
|
+
*/
|
|
210
|
+
export function daypicker({ day, days = [], onChange }) {
|
|
211
|
+
const today = todayISO();
|
|
212
|
+
const known = new Map(days.map((entry) => [entry.day, entry]));
|
|
213
|
+
|
|
214
|
+
// The listed days, plus the selected one if it is outside what the server
|
|
215
|
+
// returned — a day you are looking at must always appear in the list that
|
|
216
|
+
// claims to be showing which day you are looking at.
|
|
217
|
+
const listed = known.has(day) ? [...days] : [{ day, records: null }, ...days];
|
|
218
|
+
|
|
219
|
+
const step = (delta) => {
|
|
220
|
+
const next = shiftISO(day, delta);
|
|
221
|
+
if (next > today) return;
|
|
222
|
+
onChange(next);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const arrow = (name, label, delta, disabled) => h('button.stepper__btn', {
|
|
226
|
+
type: 'button',
|
|
227
|
+
title: label,
|
|
228
|
+
'aria-label': label,
|
|
229
|
+
disabled,
|
|
230
|
+
onclick: () => step(delta),
|
|
231
|
+
}, [icon(name, { size: 16 })]);
|
|
232
|
+
|
|
233
|
+
return h('div.stepper', {}, [
|
|
234
|
+
arrow('left', 'The day before', -1, false),
|
|
235
|
+
dropdown({
|
|
236
|
+
label: 'Day',
|
|
237
|
+
value: day,
|
|
238
|
+
onChange,
|
|
239
|
+
options: listed.slice(0, 120).map((entry) => ({
|
|
240
|
+
id: entry.day,
|
|
241
|
+
label: dayLabel(entry.day),
|
|
242
|
+
hint: dayHint(entry),
|
|
243
|
+
})),
|
|
244
|
+
}),
|
|
245
|
+
arrow('right', 'The day after', 1, day >= today),
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What a day looks like before you open it.
|
|
251
|
+
*
|
|
252
|
+
* `records: null` means the rollup cache has not folded that day yet, which is
|
|
253
|
+
* not the same as a day with nothing in it — so it says so rather than claiming
|
|
254
|
+
* a zero it has not measured.
|
|
255
|
+
*/
|
|
256
|
+
function dayHint(entry) {
|
|
257
|
+
if (entry.records === null || entry.records === undefined) return 'not counted yet';
|
|
258
|
+
if (!entry.records) return 'nothing recorded';
|
|
259
|
+
const parts = [`${compact(entry.records)} records`];
|
|
260
|
+
if (entry.activeMs) parts.push(duration(entry.activeMs));
|
|
261
|
+
return parts.join(' \u00b7 ');
|
|
262
|
+
}
|
|
263
|
+
|
|
191
264
|
export function replace(node, ...children) {
|
|
192
265
|
node.replaceChildren(...children.flat().filter(Boolean));
|
|
193
266
|
return node;
|
|
@@ -212,11 +285,6 @@ export function compact(value) {
|
|
|
212
285
|
return String(Math.round(value));
|
|
213
286
|
}
|
|
214
287
|
|
|
215
|
-
export function usd(value) {
|
|
216
|
-
if (value === null || value === undefined) return DASH;
|
|
217
|
-
return value >= 1000 ? `$${Math.round(value).toLocaleString()}` : `$${value.toFixed(2)}`;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
288
|
export function duration(ms) {
|
|
221
289
|
if (!ms || ms < 0) return '0m';
|
|
222
290
|
const minutes = Math.round(ms / 60_000);
|
|
@@ -226,6 +294,24 @@ export function duration(ms) {
|
|
|
226
294
|
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
/**
|
|
298
|
+
* A per-hour rate. `compact` does the magnitude; this only adds the unit, so a
|
|
299
|
+
* burn rate and a token count never disagree about what "1.2M" means.
|
|
300
|
+
*
|
|
301
|
+
* Null is an em dash, not 0 — a rate we could not measure must not read as a
|
|
302
|
+
* rate of nothing. See analytics/metrics/window.mjs.
|
|
303
|
+
*/
|
|
304
|
+
export function perHour(value) {
|
|
305
|
+
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
|
306
|
+
return `${compact(value)}/h`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A ratio printed as a multiplier: 63.6 → "64×". */
|
|
310
|
+
export function times(value, digits = 0) {
|
|
311
|
+
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
|
312
|
+
return `${value.toFixed(digits)}×`;
|
|
313
|
+
}
|
|
314
|
+
|
|
229
315
|
export function percent(fraction, digits = 0) {
|
|
230
316
|
if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return DASH;
|
|
231
317
|
return `${(fraction * 100).toFixed(digits)}%`;
|
|
@@ -243,6 +329,36 @@ export const when = (ts) => (ts ? new Date(ts).toLocaleString(undefined, { month
|
|
|
243
329
|
export const clock = (ts) => new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
244
330
|
export const dayName = (iso) => new Date(`${iso}T12:00`).toLocaleDateString(undefined, { weekday: 'short' });
|
|
245
331
|
|
|
332
|
+
/** Today's local date as YYYY-MM-DD — the same shape analytics/ranges.mjs speaks. */
|
|
333
|
+
export function todayISO(now = new Date()) {
|
|
334
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
335
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
336
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function shiftISO(iso, delta) {
|
|
340
|
+
const [year, month, date] = iso.split('-').map(Number);
|
|
341
|
+
// Noon, so a DST boundary cannot push the result onto the wrong calendar day.
|
|
342
|
+
return todayISO(new Date(year, month - 1, date + delta, 12));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A day, named the way a person would say it.
|
|
347
|
+
*
|
|
348
|
+
* "Today" and "Yesterday" beat a date for the two days somebody is nearly always
|
|
349
|
+
* looking at, and a date beats a weekday for everything older — "Tuesday" is
|
|
350
|
+
* ambiguous the moment more than one Tuesday is on offer.
|
|
351
|
+
*/
|
|
352
|
+
export function dayLabel(iso, { long = false } = {}) {
|
|
353
|
+
const today = todayISO();
|
|
354
|
+
if (iso === today) return 'Today';
|
|
355
|
+
if (iso === shiftISO(today, -1)) return 'Yesterday';
|
|
356
|
+
const date = new Date(`${iso}T12:00`);
|
|
357
|
+
return date.toLocaleDateString(undefined, long
|
|
358
|
+
? { weekday: 'long', day: 'numeric', month: 'long' }
|
|
359
|
+
: { weekday: 'short', day: 'numeric', month: 'short' });
|
|
360
|
+
}
|
|
361
|
+
|
|
246
362
|
// ── Components ─────────────────────────────────────────────────────────────
|
|
247
363
|
|
|
248
364
|
export function card(title, body, { note = null, actions = null, className = '' } = {}) {
|
|
@@ -3,16 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Four questions, in the order people actually ask them:
|
|
5
5
|
* 1. Am I working well? the score, and what dragged it
|
|
6
|
-
* 2. What did it
|
|
7
|
-
* 3. When did I work?
|
|
6
|
+
* 2. What did it take? throughput and burn rate, with the shape of it
|
|
7
|
+
* 3. When did I work? hour by hour on one day, day by day over a range
|
|
8
8
|
* 4. What should I change? the top findings, with their evidence
|
|
9
9
|
*
|
|
10
10
|
* Anything that does not answer one of those four is not on this page.
|
|
11
|
+
*
|
|
12
|
+
* The page has two shapes, because one day and thirty days are different
|
|
13
|
+
* questions rather than the same question at different widths. Over a range the
|
|
14
|
+
* x axis is days; on a single day it is hours. A week's chart drawn with one
|
|
15
|
+
* point on it is not a simpler chart, it is a broken one — and the single day is
|
|
16
|
+
* now the default, so it is the shape that has to be right first.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { api } from '../api.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
20
|
+
import {
|
|
21
|
+
h, card, figure, trend, chip, dot, meter, legend, empty,
|
|
22
|
+
compact, duration, percent, perHour, times, dayLabel,
|
|
23
|
+
} from '../ui.js';
|
|
24
|
+
import { dial, capsules, timeline, dualLine, dayStrip } from '../charts.js';
|
|
16
25
|
|
|
17
26
|
/** Tone by what a tool costs you, which is what the colour is claiming. */
|
|
18
27
|
const FAMILY_TONE = {
|
|
@@ -20,17 +29,25 @@ const FAMILY_TONE = {
|
|
|
20
29
|
edit: 'white', execute: 'white', web: 'white', mcp: 'white', other: 'white',
|
|
21
30
|
};
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
const NAMES = { 'claude-code': 'Claude Code', codex: 'Codex' };
|
|
33
|
+
|
|
34
|
+
export async function render({ range, days, go, setDay, setScope }) {
|
|
24
35
|
const [data, practices] = await Promise.all([api.overview(range), api.practices()]);
|
|
25
36
|
|
|
26
37
|
if ((data.ledger?.seq ?? -1) < 0) return firstRun();
|
|
27
38
|
|
|
39
|
+
// The server's answer, not the client's guess: it resolved the spec, so it is
|
|
40
|
+
// the one that knows whether this turned out to be a single day.
|
|
41
|
+
const oneDay = data.range.days.length === 1;
|
|
42
|
+
if (oneDay && !data.metrics.records) return quietDay(data.range.days[0], days, { setDay, setScope });
|
|
43
|
+
|
|
44
|
+
|
|
28
45
|
// Fixed top row, and the bottom row takes whatever is left — so the view is
|
|
29
46
|
// exactly one screen and the findings list scrolls inside its own card.
|
|
30
47
|
return h('div.content--fit', {
|
|
31
48
|
style: 'display:grid;grid-template-rows:auto minmax(0,1fr);gap:var(--s4);min-height:0',
|
|
32
49
|
}, [
|
|
33
|
-
h('div.grid.g-3', {}, [scoreCard(data), throughputCard(data),
|
|
50
|
+
h('div.grid.g-3', {}, [scoreCard(data), throughputCard(data, oneDay), shapeCard(data, oneDay)]),
|
|
34
51
|
h('div.grid.fill', { style: 'grid-template-columns:minmax(0,1fr) minmax(0,1.6fr)' }, [
|
|
35
52
|
distributionCard(data),
|
|
36
53
|
fixCard(practices.findings, go),
|
|
@@ -59,6 +76,54 @@ function firstRun() {
|
|
|
59
76
|
]);
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A day with nothing on it.
|
|
81
|
+
*
|
|
82
|
+
* This became the common case the moment one day became the default: open the
|
|
83
|
+
* dashboard before you have started and every chart is empty. Five empty charts
|
|
84
|
+
* read as a broken tool, so the day says it is quiet in words and then offers the
|
|
85
|
+
* two ways out — the last day that does have something on it, and the week.
|
|
86
|
+
*
|
|
87
|
+
* The suggested day comes from the picker's own index, so it is never a guess
|
|
88
|
+
* about which day had work: it is the same list the dropdown is showing. The
|
|
89
|
+
* shell fetches that list in the background, which on a first load can land
|
|
90
|
+
* after this panel — so the panel asks for it when it arrives empty rather than
|
|
91
|
+
* dropping the one button that gets somebody out of an empty day.
|
|
92
|
+
*/
|
|
93
|
+
async function quietDay(theDay, days = [], { setDay, setScope }) {
|
|
94
|
+
let index = days;
|
|
95
|
+
if (!index.length) {
|
|
96
|
+
index = await api.days().then((body) => body.days ?? []).catch(() => []);
|
|
97
|
+
}
|
|
98
|
+
const previous = index.find((entry) => entry.day < theDay && entry.records > 0);
|
|
99
|
+
|
|
100
|
+
return h('div', { style: 'display:grid;place-items:center;height:100%' }, [
|
|
101
|
+
h('div', { style: 'max-width:540px;display:grid;gap:18px;justify-items:center;text-align:center' }, [
|
|
102
|
+
h('div', {
|
|
103
|
+
style: 'font-size:24px;font-weight:750;letter-spacing:-0.02em;text-transform:uppercase',
|
|
104
|
+
text: `Nothing recorded ${preposition(theDay)}`,
|
|
105
|
+
}),
|
|
106
|
+
h('p', { style: 'color:var(--ink-2);line-height:1.7;margin:0',
|
|
107
|
+
text: 'The chain is intact and recording — this day simply has no sessions on it yet. Step back a day with the arrows, or widen the window.' }),
|
|
108
|
+
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;justify-content:center' }, [
|
|
109
|
+
previous
|
|
110
|
+
? h('button.btn.btn--lime', {
|
|
111
|
+
text: `Go to ${dayLabel(previous.day)}`,
|
|
112
|
+
onclick: () => setDay(previous.day),
|
|
113
|
+
})
|
|
114
|
+
: null,
|
|
115
|
+
h('button.btn.btn--ghost', { text: 'Last 7 days', onclick: () => setScope('7d') }),
|
|
116
|
+
]),
|
|
117
|
+
]),
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** "today" reads better than "on today"; a date needs the preposition. */
|
|
122
|
+
function preposition(theDay) {
|
|
123
|
+
const label = dayLabel(theDay, { long: true });
|
|
124
|
+
return label === 'Today' || label === 'Yesterday' ? label.toLowerCase() : `on ${label}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
62
127
|
// ── 1. Score ───────────────────────────────────────────────────────────────
|
|
63
128
|
|
|
64
129
|
function scoreCard({ score, scoreDelta }) {
|
|
@@ -89,7 +154,7 @@ function scoreCard({ score, scoreDelta }) {
|
|
|
89
154
|
], { note: 'weighted' });
|
|
90
155
|
}
|
|
91
156
|
|
|
92
|
-
// ── 2. Throughput and
|
|
157
|
+
// ── 2. Throughput and what it took ─────────────────────────────────────────
|
|
93
158
|
|
|
94
159
|
/**
|
|
95
160
|
* Which agent the work came from.
|
|
@@ -105,55 +170,118 @@ function sourceChips(metrics) {
|
|
|
105
170
|
return sources.map(([id, bucket]) => chip(`${NAMES[id] ?? id} · ${compact(bucket.toolCalls)}`));
|
|
106
171
|
}
|
|
107
172
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Throughput, and the rate the usage window went at.
|
|
175
|
+
*
|
|
176
|
+
* The second figure used to be a dollar amount. It is a burn rate now, because
|
|
177
|
+
* a Claude plan bills a month and not a token: the invented price was the one
|
|
178
|
+
* number on this page nobody is charged. New tokens per active hour IS the
|
|
179
|
+
* scarcity on a subscription, and unlike a price it moves when you change how
|
|
180
|
+
* you work. analytics/metrics/window.mjs has the full argument, including why
|
|
181
|
+
* the rate divides new tokens rather than the cache-read total.
|
|
182
|
+
*/
|
|
183
|
+
function throughputCard({ headline, previousHeadline: before, series, metrics }, oneDay) {
|
|
111
184
|
return card('Throughput', [
|
|
112
185
|
h('div.pair', {}, [
|
|
113
186
|
figure('Active time', duration(headline.activeMs), { small: true }),
|
|
114
|
-
figure('
|
|
187
|
+
figure('New tokens / active hour', perHour(headline.freshPerHour), { small: true }),
|
|
115
188
|
]),
|
|
189
|
+
|
|
190
|
+
// A single day gets counts where a range gets a chart. The hourly SHAPE of a
|
|
191
|
+
// day is the next card's entire job, and two charts of the same series is
|
|
192
|
+
// worse than one — so this side spends the space on the numbers instead.
|
|
193
|
+
oneDay
|
|
194
|
+
? h('div.pair', {}, [
|
|
195
|
+
figure('Tool calls', compact(headline.toolCalls), { small: true }),
|
|
196
|
+
figure('Prompts', String(headline.prompts), { small: true }),
|
|
197
|
+
figure('Sessions', String(headline.sessions), { small: true }),
|
|
198
|
+
])
|
|
199
|
+
: null,
|
|
200
|
+
|
|
116
201
|
// One trend line, not one per figure: repeating "no prior period" twice
|
|
117
202
|
// spends two rows saying nothing.
|
|
118
203
|
trend(headline.toolCalls, before.toolCalls, { format: (value) => `${compact(value)} calls` }),
|
|
119
|
-
|
|
204
|
+
|
|
205
|
+
oneDay ? null : h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
|
|
120
206
|
dualLine(
|
|
121
|
-
series.map((
|
|
122
|
-
series.map((
|
|
207
|
+
series.map((entry) => entry.toolCalls),
|
|
208
|
+
series.map((entry) => entry.tokens / 1000),
|
|
123
209
|
{ height: 110 },
|
|
124
210
|
),
|
|
125
211
|
]),
|
|
126
|
-
legend([
|
|
212
|
+
oneDay ? null : legend([
|
|
127
213
|
{ tone: 'lime', label: 'tool calls' },
|
|
128
214
|
{ tone: 'orange', label: 'tokens' },
|
|
129
215
|
]),
|
|
130
|
-
|
|
216
|
+
|
|
217
|
+
h('div', {
|
|
218
|
+
style: `display:flex;gap:10px;flex-wrap:wrap;align-content:end${oneDay ? ';flex:1;min-height:0' : ''}`,
|
|
219
|
+
}, [
|
|
131
220
|
chip(`${percent(headline.cacheHitRate)} from cache`, 'lime'),
|
|
132
|
-
chip(`${compact(headline.tokens)} tokens`),
|
|
133
|
-
|
|
221
|
+
chip(`${compact(headline.tokens)} tokens read`),
|
|
222
|
+
chip(`${compact(headline.fresh)} new`),
|
|
223
|
+
// Restating the total under another name is not a second number, so this
|
|
224
|
+
// only appears once there is more than one ask to average over.
|
|
225
|
+
headline.prompts > 1 && headline.freshPerPrompt !== null
|
|
226
|
+
? chip(`${compact(headline.freshPerPrompt)} per prompt`)
|
|
227
|
+
: null,
|
|
228
|
+
headline.cacheLeverage === null ? null : chip(`${times(headline.cacheLeverage)} context reuse`),
|
|
134
229
|
...sourceChips(metrics),
|
|
135
230
|
]),
|
|
136
|
-
]);
|
|
231
|
+
], { note: oneDay ? 'this day' : null });
|
|
137
232
|
}
|
|
138
233
|
|
|
139
234
|
// ── 3. When the work happened ──────────────────────────────────────────────
|
|
140
235
|
|
|
141
|
-
function
|
|
236
|
+
function shapeCard(data, oneDay) {
|
|
237
|
+
return oneDay ? hourCard(data) : dayCard(data);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One day, by the hour, in minutes worked.
|
|
242
|
+
*
|
|
243
|
+
* Minutes and not event counts: twelve tool calls in an hour is not the same
|
|
244
|
+
* thing as twelve minutes of work, and a day drawn from record counts reports a
|
|
245
|
+
* busy minute as a busy hour. analytics/metrics/time.mjs folds both series for
|
|
246
|
+
* exactly this reason.
|
|
247
|
+
*/
|
|
248
|
+
function hourCard({ hourActiveMs, hours }) {
|
|
249
|
+
const minutes = (hourActiveMs ?? []).map((ms) => Math.round(ms / 60_000));
|
|
250
|
+
const worked = minutes.reduce((sum, value) => sum + value, 0);
|
|
251
|
+
|
|
252
|
+
const slots = minutes.map((value, hour) => ({
|
|
253
|
+
label: String(hour).padStart(2, '0'),
|
|
254
|
+
value,
|
|
255
|
+
tone: 'lime',
|
|
256
|
+
title: value
|
|
257
|
+
? `${String(hour).padStart(2, '0')}:00 · ${value}m active · ${hours?.[hour] ?? 0} events`
|
|
258
|
+
: `${String(hour).padStart(2, '0')}:00 · nothing recorded`,
|
|
259
|
+
}));
|
|
260
|
+
|
|
261
|
+
return card('Active minutes by hour', [
|
|
262
|
+
worked
|
|
263
|
+
? dayStrip(slots, { height: 150, labelEvery: 3, label: 'active minutes by hour of the day' })
|
|
264
|
+
: empty('No active time recorded on this day'),
|
|
265
|
+
legend([{ tone: 'lime', label: 'minutes worked' }], `${worked}m`),
|
|
266
|
+
], { note: '24 hours, local' });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function dayCard({ series }) {
|
|
142
270
|
const days = series.slice(-9);
|
|
143
|
-
const maxMinutes = Math.max(...days.map((
|
|
271
|
+
const maxMinutes = Math.max(...days.map((entry) => entry.activeMs / 60_000), 10);
|
|
144
272
|
const step = Math.max(10, Math.ceil(maxMinutes / 4 / 10) * 10);
|
|
145
273
|
const ticks = [];
|
|
146
274
|
for (let value = 0; value <= maxMinutes + step; value += step) ticks.push({ at: value, label: String(value) });
|
|
147
275
|
|
|
148
|
-
const bars = days.map((
|
|
149
|
-
row:
|
|
276
|
+
const bars = days.map((entry) => ({
|
|
277
|
+
row: entry.day.slice(5).replace('-', '.'),
|
|
150
278
|
start: 0,
|
|
151
|
-
end:
|
|
152
|
-
value: `${Math.round(
|
|
279
|
+
end: entry.activeMs / 60_000,
|
|
280
|
+
value: `${Math.round(entry.activeMs / 60_000)}m`,
|
|
153
281
|
empty: 'no activity',
|
|
154
282
|
// Orange marks a day the context window ran out — the thing worth spotting
|
|
155
283
|
// in a week at a glance.
|
|
156
|
-
tone:
|
|
284
|
+
tone: entry.compacts > 0 ? 'orange' : 'lime',
|
|
157
285
|
}));
|
|
158
286
|
|
|
159
287
|
return card('Active minutes by day', [
|
|
@@ -163,7 +291,7 @@ function timelineCard({ series }) {
|
|
|
163
291
|
legend([
|
|
164
292
|
{ tone: 'lime', label: 'clean day' },
|
|
165
293
|
{ tone: 'orange', label: 'context ran out' },
|
|
166
|
-
], days.reduce((sum,
|
|
294
|
+
], days.reduce((sum, entry) => sum + Math.round(entry.activeMs / 60_000), 0)),
|
|
167
295
|
]);
|
|
168
296
|
}
|
|
169
297
|
|
|
@@ -195,6 +323,14 @@ function distributionCard({ metrics }) {
|
|
|
195
323
|
|
|
196
324
|
// ── 5. What to change ──────────────────────────────────────────────────────
|
|
197
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The findings are NOT scoped to the selected window.
|
|
328
|
+
*
|
|
329
|
+
* The practice engine reads its own recent history, because a rule about retry
|
|
330
|
+
* storms needs the run of events that produced one and cannot answer from a day
|
|
331
|
+
* you happen to be looking at. The note says so rather than letting the card
|
|
332
|
+
* imply it is about the day above it.
|
|
333
|
+
*/
|
|
198
334
|
function fixCard(findings, go) {
|
|
199
335
|
const live = findings.filter((finding) => !finding.muted).slice(0, 3);
|
|
200
336
|
|
|
@@ -209,6 +345,7 @@ function fixCard(findings, go) {
|
|
|
209
345
|
h('div.finding__fix', {}, [h('span', { text: finding.fix })]),
|
|
210
346
|
]))
|
|
211
347
|
: [empty('No rule found a pattern worth changing.')]), {
|
|
348
|
+
note: 'recent habits, not this window',
|
|
212
349
|
actions: h('button.btn.btn--ghost.btn--sm', { text: 'All habits', style: 'margin-left:auto', onclick: () => go('habits') }),
|
|
213
350
|
});
|
|
214
351
|
}
|
|
@@ -88,7 +88,7 @@ async function paint(page) {
|
|
|
88
88
|
toggle('Tool calls', config.track.tools, (v) => save('track.tools', v), 'Most of the score is built from these.'),
|
|
89
89
|
toggle('Prompts', config.track.prompts, (v) => save('track.prompts', v)),
|
|
90
90
|
toggle('Permission requests', config.track.permissions, (v) => save('track.permissions', v)),
|
|
91
|
-
toggle('Tokens and
|
|
91
|
+
toggle('Tokens and cache', config.track.transcript, (v) => save('track.transcript', v), 'Read incrementally from the session transcript.'),
|
|
92
92
|
toggle('Git commits', config.track.git, (v) => save('track.git', v)),
|
|
93
93
|
]),
|
|
94
94
|
|