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.
@@ -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 cost me? throughput and spend, with the shape of the trend
7
- * 3. When did I work? a day-by-day timeline
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 { h, card, figure, trend, chip, dot, meter, legend, empty, compact, usd, duration, percent } from '../ui.js';
15
- import { dial, capsules, timeline, dualLine } from '../charts.js';
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
- export async function render({ range, go }) {
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), timelineCard(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 spend ────────────────────────────────────────────────
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
- const NAMES = { 'claude-code': 'Claude Code', codex: 'Codex' };
109
-
110
- function throughputCard({ headline, previousHeadline: before, series, cost, metrics }) {
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('Spend', usd(headline.usd), { small: true }),
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
- h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
204
+
205
+ oneDay ? null : h('div', { style: 'flex:1;min-height:0;display:grid;align-items:center' }, [
120
206
  dualLine(
121
- series.map((day) => day.toolCalls),
122
- series.map((day) => day.tokens / 1000),
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
- h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap' }, [
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
- cost.estimated ? chip('estimated pricing') : null,
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 timelineCard({ series }) {
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((day) => day.activeMs / 60_000), 10);
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((day) => ({
149
- row: day.day.slice(5).replace('-', '.'),
276
+ const bars = days.map((entry) => ({
277
+ row: entry.day.slice(5).replace('-', '.'),
150
278
  start: 0,
151
- end: day.activeMs / 60_000,
152
- value: `${Math.round(day.activeMs / 60_000)}m`,
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: day.compacts > 0 ? 'orange' : 'lime',
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, day) => sum + Math.round(day.activeMs / 60_000), 0)),
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 cost', config.track.transcript, (v) => save('track.transcript', v), 'Read incrementally from the session transcript.'),
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