@nredd/pi-session-kit 0.4.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-session-kit
2
2
 
3
- Published to npm as `@nredd/pi-session-kit` (public). Each version is cut by the monorepo `Publish` workflow from a signed `@nredd/pi-session-kit@<version>` tag, with npm provenance and a GitHub release carrying the tarball, checksums and manifest (`docs/releases.md`). `0.4.0` adds `createSessionKit(options)` (explicit `config`, `agentDir`, `monitorRoot`, injectable `clock` and `spawnMonitor`; the default export is unchanged) and fixes leaderless orphan-group sweeps at `ps` start-time resolution.
3
+ Published to npm as `@nredd/pi-session-kit` (public). From `0.4.0`, each version is cut by the monorepo `Publish` workflow from a signed `@nredd/pi-session-kit@<version>` tag, with npm provenance and a GitHub release carrying the tarball, checksums and manifest (`docs/releases.md`); earlier versions were published manually without provenance. `0.4.0` adds `createSessionKit(options)` (explicit `config`, `agentDir`, `monitorRoot`, injectable `clock` and `spawnMonitor`; the default export is unchanged) and fixes leaderless orphan-group sweeps at `ps` start-time resolution. `0.5.0` redesigns `/stats` around the full pane (pinned header, aligned stat cells, a Models table on wide panes) and gives every chart a clock- or calendar-aligned time axis, including the 5h and 24h hourly views.
4
4
  Use the [monorepo development commands](https://github.com/nredd/pi-harness/blob/main/README.md). The Git install and standalone harness
5
5
  references below describe the legacy deployment, not installation or candidate verification of
6
6
  this package.
@@ -96,21 +96,37 @@ non-triggering display message. The overlay is TUI-only and works in Plan mode.
96
96
  `/stats` is Claude Code's `/stats` pane over fresh local sessions plus imported machine snapshots,
97
97
  split by provider and model. It NEVER performs network I/O. Machine coverage and the last successful
98
98
  remote import are shown; unsynced machines show `local-only` / `never synced`. Invalid or missing
99
- snapshots retain last-good data and show `incomplete coverage`. Overview: an activity heatmap (one column per week), headline numbers (favorite model, total tokens,
100
- sessions, longest session, active days, streaks, cache tokens, notional cost) and the token chart.
101
- Models: the chart, then every model grouped under its provider with in/out/cache/cost, two-up from
102
- 80 columns. Tab switches tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter
103
- (All, each provider, each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
99
+ snapshots retain last-good data and show `incomplete coverage`.
100
+
101
+ Pinned header: tabs and the coverage note, then the date ranges and the model filter, then a rule.
102
+ Overview: an activity heatmap (one column per week) with the headline numbers (favorite model,
103
+ total tokens, sessions, longest session, active days, streaks, cache tokens, notional cost) beside
104
+ it from about 105 columns and under it below that, then the token chart. Models: the chart, then every
105
+ model grouped under its provider. From about 100 columns that is a table (tokens, share, in, out, cache
106
+ read, cache write, notional cost; a subtotal line per provider), else three-line cards. Tab switches
107
+ tabs, `r` cycles All / 7d / 30d / 5h / 24h (default All), `m`/`M` cycle the filter (All, each provider,
108
+ each `provider/model`), `j/k` or arrows scroll, `q` or Escape closes.
104
109
 
105
110
  - The chart is an asciichart-style line plot (`╭╮╰╯`), one line per provider under All, else the top
106
111
  three models. Where lines overlap they merge into junctions (`┴┬├┤┼`) so every line stays
107
112
  connected; the larger series keeps the colour. Line colours start at the theme's `accent` hue,
108
113
  saturated, with hues spread evenly over the lines drawn (two providers get opposite hues), so
109
- muted themes still separate them. The heatmap and the chart span 75% of the pane, with the same right edge; the heatmap shows as
110
- many weeks as fit, never less than a year when the pane allows. Days sum into 1/2/3/7/14/30-day buckets (longer
111
- when needed) so the range fits the width; short ranges are interpolated, not stepped. Y ticks land
112
- on round values with little headroom. Height follows the pane in whole tick steps (5 to 40 rows):
113
- Overview grows the chart into the rows it has left, Models gives it about half.
114
+ muted themes still separate them. The title row carries the legend on its right (it wraps under the
115
+ title on narrow panes). Every block spans the whole pane, except the heatmap, which shows as many
116
+ weeks as fit up to two years. Days sum into 1/2/3/7/14/30-day buckets (longer when needed) so the
117
+ range fits the width; short ranges are interpolated, not stepped. Y ticks land on round values with
118
+ little headroom. Height follows the pane in whole tick steps (5 to 40 rows): Overview grows the chart
119
+ into the rows it has left, Models gives it what a short list leaves, at least half.
120
+ - X axis: a baseline with a mark under every tick, labels centred on their bucket's column (the first and
121
+ last clamp to the plot edges). Ticks are evenly spaced, at most 13, never overlap and are never cut; the
122
+ finest set whose labels fit wins, so a wider pane gets more of them. 5h and 24h label hours starting on
123
+ the clock (`09:00`, or `9a` / `12p` when narrow; every hour, 2, 3, 4, 6, 12 or 24 hours apart; across a
124
+ DST change they stay evenly spaced, so later labels shift by the hour) with a row of dates where they
125
+ fit: each new local day first, then the first bucket. Day buckets: a week of single days gets `Wed 7`
126
+ (or `Wed`), longer ranges get dates on every day, 2 or 3 days, every Monday or every other Monday
127
+ (counted back from today for multi-day buckets), then month names, quarters, half years and years, with
128
+ `'26` only where the ticks span more than one year. A pane too narrow for any label draws the baseline
129
+ alone.
114
130
  - Rolling 5h/24h use the original call timestamps in zero-filled one-hour UTC buckets, with
115
131
  partial edges labeled. Labels use viewer-local time and UTC offsets, including repeated DST
116
132
  hours. The same rolling filter feeds chart/model/overview totals; future calls are ignored in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nredd/pi-session-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -34,6 +34,8 @@ export interface ModelTotals {
34
34
 
35
35
  export interface DayPoint {
36
36
  day: string;
37
+ /** Bucket start instant; hourly series only. */
38
+ ms?: number;
37
39
  /** Tokens per series key (provider, or `provider/model`). */
38
40
  byKey: Map<string, number>;
39
41
  total: number;
@@ -210,6 +212,7 @@ export function hourlySeries(
210
212
  const points: DayPoint[] = [];
211
213
  for (let ms = from; ms <= to; ms += HOUR_MS)
212
214
  points.push({
215
+ ms,
213
216
  day: `${hourLabel(ms)}${ms < start || ms + HOUR_MS > now ? " (partial)" : ""}`,
214
217
  byKey: new Map(),
215
218
  total: 0,
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * The "Tokens per day" line chart: an asciichart-style plot (`╭╮╰╯│─`) with nice y-axis ticks and
3
- * date labels. Pure: colours go through a `paint` callback so tests can render plain text.
3
+ * time ticks in `ticks.ts`. Pure: colours go through a `paint` callback so tests can render plain text.
4
4
  *
5
5
  * Days are bucketed (1, 2, 3, 7, 14 or 30 days) so the whole range fits the width, then sampled
6
6
  * with linear interpolation across every column so a short range draws a smooth curve rather
7
7
  * than a staircase.
8
8
  */
9
9
 
10
- import { addDays, daysBetween, shortDate } from "./days.ts";
10
+ import { addDays, daysBetween } from "./days.ts";
11
11
  import { axisLabel } from "./format.ts";
12
12
 
13
13
  export const BUCKET_SIZES = [1, 2, 3, 7, 14, 30] as const;
@@ -194,7 +194,7 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
194
194
  const y = height - r;
195
195
  const tick = Number.isInteger(y / perTick) ? y / perTick : -1;
196
196
  const label = tick >= 0 ? (labels[tick] ?? "") : "";
197
- const axis = y === 0 ? "┼" : tick >= 0 ? "┤" : "│";
197
+ const axis = tick >= 0 ? "┤" : "│";
198
198
  let body = "";
199
199
  let run = "";
200
200
  let runColor: C | undefined;
@@ -214,34 +214,3 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
214
214
  }
215
215
  return { lines, gutter: labelWidth + 2, max };
216
216
  }
217
-
218
- /**
219
- * Two to five date labels under a plot of `width` columns spanning `buckets`: first and last always,
220
- * evenly spaced ones between when they fit with two spaces of air. Never past the plot's right edge.
221
- */
222
- export function xAxis(buckets: readonly Bucket[], width: number, gutter: number): string {
223
- if (buckets.length === 0 || width <= 0) return "";
224
- const last = buckets.length - 1;
225
- const at = (col: number) => buckets[Math.round((col * last) / Math.max(1, width))]?.start ?? "";
226
- const line = new Array<string>(width).fill(" ");
227
- const place = (col: number, text: string): boolean => {
228
- const start = Math.max(0, Math.min(width - text.length, col));
229
- for (let i = Math.max(0, start - 2); i < Math.min(width, start + text.length + 2); i++) {
230
- if (line[i] !== " ") return false;
231
- }
232
- for (let i = 0; i < text.length && start + i < width; i++) line[start + i] = text[i] ?? " ";
233
- return true;
234
- };
235
- const first = shortDate(buckets[0]?.start ?? "");
236
- const end = shortDate(buckets[last]?.start ?? "");
237
- // The newest date first: on a plot too narrow for both, today's end of the range wins.
238
- if (last > 0) place(width - end.length, end);
239
- place(0, first);
240
- if (last === 0) return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
241
- const slots = Math.max(1, Math.min(4, Math.floor(width / 14)));
242
- for (let i = 1; i < slots; i++) {
243
- const col = Math.round((i * width) / slots);
244
- place(col - 3, shortDate(at(col)));
245
- }
246
- return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
247
- }
@@ -21,6 +21,7 @@ import {
21
21
  matches,
22
22
  modelName,
23
23
  modelTotals,
24
+ type Overview,
24
25
  overview,
25
26
  RANGE_LABEL,
26
27
  RANGES,
@@ -29,12 +30,13 @@ import {
29
30
  TOKENS_NOTE,
30
31
  tokensOf,
31
32
  } from "./aggregate.ts";
32
- import { bucketize, GUTTER, plot, TICKS, xAxis } from "./chart.ts";
33
- import { dayKey, shortDate } from "./days.ts";
33
+ import { bucketize, GUTTER, plot, TICKS } from "./chart.ts";
34
+ import { dayKey, keyDate, shortDate } from "./days.ts";
34
35
  import { duration, formatTokens, money, percent } from "./format.ts";
35
- import { DAY_LABEL_WIDTH, heatmap, weeksFor, YEAR_WEEKS } from "./heatmap.ts";
36
+ import { DAY_LABEL_WIDTH, heatmap, MAX_WEEKS, MONTH_OVERFLOW, weeksFor, YEAR_WEEKS } from "./heatmap.ts";
36
37
  import type { Call, Session } from "./load.ts";
37
38
  import { seriesPalette } from "./palette.ts";
39
+ import { timeAxis } from "./ticks.ts";
38
40
 
39
41
  type ThemeLike = Pick<Theme, "fg" | "bg" | "bold" | "style" | "colors">;
40
42
  export type Tab = "Overview" | "Models";
@@ -43,16 +45,10 @@ const TABS: readonly Tab[] = ["Overview", "Models"];
43
45
  const SERIES = 3;
44
46
  /** Narrowest plot `plot` can draw; only panes too narrow for a 6-week heatmap hit it. */
45
47
  const MIN_PLOT_WIDTH = 2;
46
- /** Share of the pane the heatmap and chart span. */
47
- export const CONTENT_SHARE = 0.75;
48
48
 
49
- /**
50
- * Columns the heatmap and the chart both span in a `paneWidth` pane (after the 1-column margin):
51
- * `CONTENT_SHARE` of it, but never narrower than a year of heatmap when that fits.
52
- */
49
+ /** Columns every block spans in a `paneWidth` pane: all of it after the 1-column margin. */
53
50
  export function contentWidth(paneWidth: number): number {
54
- const inner = Math.max(1, paneWidth - 1);
55
- return Math.min(inner, Math.max(Math.round(inner * CONTENT_SHARE), DAY_LABEL_WIDTH + YEAR_WEEKS));
51
+ return Math.max(1, paneWidth - 1);
56
52
  }
57
53
 
58
54
  /** Tallest chart, in rows above the zero line. */
@@ -64,8 +60,8 @@ export function chartHeight(available: number): number {
64
60
  return Math.max(TICKS, Math.min(MAX_CHART_HEIGHT, steps));
65
61
  }
66
62
 
67
- /** Model list goes two-up from this width. */
68
- const TWO_COLUMN_WIDTH = 80;
63
+ /** Columns between the heatmap and the numbers beside it, and between number columns. */
64
+ const GAP = 4;
69
65
 
70
66
  export interface StatsViewOptions {
71
67
  calls: readonly Call[];
@@ -84,6 +80,9 @@ export interface StatsViewOptions {
84
80
  }
85
81
 
86
82
  /** One scroll step of the model list: a provider header, or one or two models side by side. */
83
+ /** Per-provider or per-model sums the list shows. */
84
+ type Totals = Pick<ModelTotals, "input" | "output" | "cacheRead" | "cacheWrite" | "cost">;
85
+
87
86
  interface ListRow {
88
87
  lines: string[];
89
88
  /** Positions of its models in list order, for the `1-4 of N` hint. */
@@ -151,7 +150,7 @@ export class StatsView implements Component {
151
150
  render(width: number): string[] {
152
151
  const w = Math.max(1, Math.floor(width));
153
152
  const rows = Math.max(4, Math.floor(this.o.rows()));
154
- const header = [this.tabsLine(), this.paint("muted", this.o.coverage ?? "local-only · never synced")];
153
+ const header = this.header(contentWidth(w), rows);
155
154
  const room = Math.max(0, rows - header.length - 2);
156
155
  let body: string[];
157
156
  let hint = "";
@@ -165,6 +164,24 @@ export class StatsView implements Component {
165
164
  return [...header, ...body, "", this.help(hint)].map((line) => truncateToWidth(` ${line}`, w, ""));
166
165
  }
167
166
 
167
+ /**
168
+ * Tabs and warning with the coverage note flush right, then the range and filter (what `r` and `m`
169
+ * change), then a rule. Pinned above the scrolling body. Coverage drops under the selectors when the
170
+ * tabs row cannot hold it; on a very short pane the rule goes first.
171
+ */
172
+ private header(inner: number, rows: number): string[] {
173
+ const coverage = this.paint("muted", this.o.coverage ?? "local-only · never synced");
174
+ const tabs = this.tabsLine();
175
+ const joined = spread(tabs, coverage, inner);
176
+ const lines = [
177
+ joined ?? tabs,
178
+ ...this.selectors(inner),
179
+ ...(joined ? [] : [coverage]),
180
+ this.paint("dim", "─".repeat(inner)),
181
+ ];
182
+ return lines.slice(0, Math.max(2, Math.min(lines.length, rows - 2)));
183
+ }
184
+
168
185
  /**
169
186
  * `↑ ↓ 3-6 of 14 models` from the list positions on screen; arrows only where more models are.
170
187
  * The list follows the chart, so a window with no model in it is always above the list. Empty when
@@ -210,12 +227,31 @@ export class StatsView implements Component {
210
227
  return `${t.bold(this.paint("accent", "/stats"))} ${tabs}${bad}`;
211
228
  }
212
229
 
213
- private selectors(): string {
230
+ /** Range choices and the filter on one line, or the filter on its own line when they do not fit. */
231
+ private selectors(inner: number): string[] {
214
232
  const t = this.o.theme;
215
233
  const ranges = RANGES.map((r) =>
216
234
  r === this.range ? t.bold(this.paint("accent", RANGE_LABEL[r])) : this.paint("dim", RANGE_LABEL[r]),
217
235
  ).join(this.paint("dim", " · "));
218
- return `${ranges} ${this.paint("muted", "filter:")} ${t.bold(sanitize(filterLabel(this.filter)))}`;
236
+ const filter = `${this.paint("muted", "filter:")} ${t.bold(sanitize(filterLabel(this.filter)))}`;
237
+ const line = `${ranges} ${filter}`;
238
+ return visibleWidth(line) <= inner ? [line] : [ranges, truncateToWidth(filter, inner, "…")];
239
+ }
240
+
241
+ /** Muted `parts` joined by a dim ` · `, wrapped onto more lines rather than cut. */
242
+ private wrapParts(parts: readonly string[], inner: number): string[] {
243
+ const sep = this.paint("dim", " · ");
244
+ const lines: string[] = [];
245
+ let line = "";
246
+ for (const part of parts.map((p) => this.paint("muted", p))) {
247
+ if (line && visibleWidth(line) + 3 + visibleWidth(part) > inner) {
248
+ lines.push(line);
249
+ line = "";
250
+ }
251
+ line += (line ? sep : "") + part;
252
+ }
253
+ if (line) lines.push(line);
254
+ return lines.map((l) => truncateToWidth(l, inner, "…"));
219
255
  }
220
256
 
221
257
  private help(hint = ""): string {
@@ -228,49 +264,96 @@ export class StatsView implements Component {
228
264
  return { all, ranged: all.filter((c) => inRange(this.range, this.today, c.ms, this.now)) };
229
265
  }
230
266
 
267
+ /**
268
+ * Headline numbers as `label value` cells, in reading order: favorite model and total tokens,
269
+ * sessions and longest session, and so on.
270
+ */
271
+ private statCells(ov: Overview): { label: string; value: string }[] {
272
+ const t = this.o.theme;
273
+ const v = (s: string) => this.paint("accent", s);
274
+ const days = (n: number) => `${t.bold(v(String(n)))} ${n === 1 ? "day" : "days"}`;
275
+ return [
276
+ { label: "Favorite model", value: t.bold(v(ov.favorite ?? "-")) },
277
+ { label: "Total tokens", value: `${v(formatTokens(ov.totalTokens))} ${this.paint("muted", TOKENS_NOTE)}` },
278
+ { label: "Sessions", value: v(String(ov.sessions)) },
279
+ { label: "Longest session", value: v(duration(ov.longestSessionMs)) },
280
+ { label: "Active days", value: `${v(String(ov.activeDays))}${this.paint("muted", `/${ov.rangeDays}`)}` },
281
+ { label: "Longest streak", value: days(ov.longestStreak) },
282
+ { label: "Most active day", value: v(ov.mostActiveDay ? shortDate(ov.mostActiveDay) : "-") },
283
+ { label: "Current streak", value: days(ov.currentStreak) },
284
+ {
285
+ label: "Cache tokens",
286
+ value: `${v(formatTokens(ov.cacheRead))} read · ${v(formatTokens(ov.cacheWrite))} write`,
287
+ },
288
+ { label: "Notional cost", value: v(money(ov.cost)) },
289
+ ];
290
+ }
291
+
292
+ /**
293
+ * Cells in `columns` columns, row-major, each column's labels and values aligned; `columns` shrinks
294
+ * until the grid fits `width`.
295
+ */
296
+ private statGrid(cells: readonly { label: string; value: string }[], width: number, columns: number): string[] {
297
+ const widthOf = (cols: number) => {
298
+ const per = Array.from({ length: cols }, (_, c) => cells.filter((_, i) => i % cols === c));
299
+ // Every label is padded to the column's longest, so a cell is that plus the widest value.
300
+ return per.map((col) => {
301
+ const label = Math.max(0, ...col.map((x) => x.label.length));
302
+ return { label, cell: label + 2 + Math.max(0, ...col.map((x) => visibleWidth(x.value))) };
303
+ });
304
+ };
305
+ let cols = Math.max(1, Math.min(columns, cells.length));
306
+ while (cols > 1 && widthOf(cols).reduce((n, c) => n + c.cell, 0) + GAP * (cols - 1) > width) cols--;
307
+ const sizes = widthOf(cols);
308
+ const out: string[] = [];
309
+ for (let r = 0; r < cells.length; r += cols) {
310
+ out.push(
311
+ cells
312
+ .slice(r, r + cols)
313
+ .map((cell, c) => {
314
+ const size = sizes[c];
315
+ const text = `${this.paint("muted", cell.label.padEnd(size?.label ?? 0))} ${cell.value}`;
316
+ return c < cols - 1 ? padTo(text, (size?.cell ?? 0) + GAP) : text;
317
+ })
318
+ .join(""),
319
+ );
320
+ }
321
+ return out.map((line) => truncateToWidth(line, width, "…"));
322
+ }
323
+
231
324
  private overviewBody(width: number, room: number): string[] {
232
325
  const { all, ranged } = this.scoped();
233
- const t = this.o.theme;
326
+ const inner = contentWidth(width);
234
327
  const out: string[] = [];
235
328
  const daily = dailyTotals(all);
236
- if (!isHourly(this.range))
237
- out.push(
238
- ...heatmap(daily, this.today, contentWidth(width), (color, text) =>
239
- color === "heat" ? this.paint("accent", text) : this.paint(color === "empty" ? "dim" : "muted", text),
240
- ),
329
+ const showHeat = !isHourly(this.range);
330
+ if (ranged.length === 0 && !showHeat) return [this.paint("muted", "No usage in this range.")];
331
+ const cells =
332
+ ranged.length === 0
333
+ ? []
334
+ : this.statCells(overview(ranged, this.o.sessions, this.range, this.today, daily.keys()));
335
+ const heat = (area: number) =>
336
+ heatmap(daily, this.today, area, (color, text) =>
337
+ color === "heat" ? this.paint("accent", text) : this.paint(color === "empty" ? "dim" : "muted", text),
241
338
  );
242
- out.push("", this.selectors(), "");
339
+ if (showHeat) {
340
+ // Numbers beside the heatmap when a year of it still fits next to them, else underneath.
341
+ const column = this.statGrid(cells, inner, 1);
342
+ const beside = Math.max(0, ...column.map(visibleWidth));
343
+ const area = Math.min(inner - GAP - beside, DAY_LABEL_WIDTH + MAX_WEEKS + MONTH_OVERFLOW);
344
+ if (cells.length > 0 && weeksFor(area) >= YEAR_WEEKS) {
345
+ const left = heat(area);
346
+ for (let i = 0; i < Math.max(left.length, column.length); i++)
347
+ out.push(`${padTo(left[i] ?? "", area + GAP)}${column[i] ?? ""}`.trimEnd());
348
+ } else {
349
+ out.push(...heat(inner));
350
+ if (cells.length > 0) out.push("", ...this.statGrid(cells, inner, 2));
351
+ }
352
+ } else out.push(...this.statGrid(cells, inner, width >= 130 ? 3 : 2));
243
353
  if (ranged.length === 0) {
244
- out.push(this.paint("muted", "No usage in this range."));
354
+ out.push("", this.paint("muted", "No usage in this range."));
245
355
  return out;
246
356
  }
247
- const ov = overview(ranged, this.o.sessions, this.range, this.today, daily.keys());
248
- const v = (s: string) => this.paint("accent", s);
249
- const pairs: [string, string][] = [
250
- [
251
- `Favorite model: ${t.bold(v(ov.favorite ?? "-"))}`,
252
- `Total tokens: ${v(formatTokens(ov.totalTokens))} ${this.paint("muted", TOKENS_NOTE)}`,
253
- ],
254
- [`Sessions: ${v(String(ov.sessions))}`, `Longest session: ${v(duration(ov.longestSessionMs))}`],
255
- [
256
- `Active days: ${v(String(ov.activeDays))}${this.paint("muted", `/${ov.rangeDays}`)}`,
257
- `Longest streak: ${t.bold(v(String(ov.longestStreak)))} ${ov.longestStreak === 1 ? "day" : "days"}`,
258
- ],
259
- [
260
- `Most active day: ${v(ov.mostActiveDay ? shortDate(ov.mostActiveDay) : "-")}`,
261
- `Current streak: ${t.bold(v(String(ov.currentStreak)))} ${ov.currentStreak === 1 ? "day" : "days"}`,
262
- ],
263
- [
264
- `Cache tokens: ${v(formatTokens(ov.cacheRead))} read · ${v(formatTokens(ov.cacheWrite))} write`,
265
- `Notional cost: ${v(money(ov.cost))}`,
266
- ],
267
- ];
268
- const col = Math.max(36, Math.min(48, Math.floor(width / 2)));
269
- const twoCol = width >= col * 2;
270
- for (const [left, right] of pairs) {
271
- if (twoCol) out.push(`${left}${" ".repeat(Math.max(2, col - visibleWidth(left)))}${right}`);
272
- else out.push(left, right);
273
- }
274
357
  // Grow the chart into whatever the pane has left, in whole tick steps.
275
358
  const smallest = this.chart(ranged, width, TICKS).lines;
276
359
  const height = chartHeight(room - out.length - 1 - smallest.length + TICKS);
@@ -279,8 +362,8 @@ export class StatsView implements Component {
279
362
  }
280
363
 
281
364
  /**
282
- * "Tokens per <bucket>", x-axis, peak/average caption and legend. One line per provider under
283
- * the "all" filter, else one per model; top three.
365
+ * Title, legend, plot, time axis and a peak/average caption. One line per provider under the "all"
366
+ * filter, else one per model; top three. The legend shares the title row when both fit.
284
367
  */
285
368
  private chart(
286
369
  ranged: readonly Call[],
@@ -288,23 +371,49 @@ export class StatsView implements Component {
288
371
  height: number,
289
372
  ): { lines: string[]; colors: Map<string, number> } {
290
373
  const t = this.o.theme;
291
- const out: string[] = [];
374
+ const inner = contentWidth(width);
292
375
  const { by, keys: top } = chartSeries(this.filter, ranged, SERIES);
293
376
  const colors = new Map(top.map((k, i): [string, number] => [k.key, i]));
294
- if (isHourly(this.range)) {
295
- const series = hourlySeries(ranged, this.range, this.now, by === "provider" ? (c) => c.provider : modelName);
296
- const plotWidth = Math.max(MIN_PLOT_WIDTH, contentWidth(width) - GUTTER);
377
+ const hourly = isHourly(this.range);
378
+ const unit = hourly ? "hour" : undefined;
379
+ const title = (name: string) => t.bold(name) + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`);
380
+ const legend = this.legend(
381
+ top.map((k) => k.key),
382
+ colors,
383
+ inner,
384
+ );
385
+ const head = (name: string): string[] => {
386
+ const row = legend.length === 1 ? spread(title(name), legend[0] ?? "", inner) : undefined;
387
+ if (row) return [row];
388
+ // A pane too narrow for the title splits the name from its unit note.
389
+ const lines =
390
+ visibleWidth(title(name)) <= inner
391
+ ? [title(name)]
392
+ : [t.bold(name), this.paint("muted", truncateToWidth(`${TOKENS_NOTE} · by ${by}`, inner, "…"))];
393
+ return [...lines, ...legend];
394
+ };
395
+ const paint = (color: number | "axis", text: string) =>
396
+ color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text);
397
+ const plotWidth = Math.max(MIN_PLOT_WIDTH, inner - GUTTER);
398
+ if (hourly) {
399
+ const series = hourlySeries(
400
+ ranged,
401
+ this.range as "5h" | "24h",
402
+ this.now,
403
+ by === "provider" ? (c) => c.provider : modelName,
404
+ );
297
405
  const p = plot(
298
406
  top.map((k, i) => ({ values: series.map((s) => s.byKey.get(k.key) ?? 0), color: i })),
299
- {
300
- width: plotWidth,
301
- height,
302
- paint: (color, text) =>
303
- color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
304
- },
407
+ { width: plotWidth, height, paint },
305
408
  );
306
- out.push(t.bold("Tokens per hour") + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`), ...p.lines);
307
- out.push(this.paint("muted", `From: ${series[0]?.day}`), this.paint("muted", `To: ${series.at(-1)?.day}`));
409
+ const axis = timeAxis(
410
+ series.map((s) => s.ms ?? 0),
411
+ { kind: "hour" },
412
+ plotWidth,
413
+ p.gutter,
414
+ );
415
+ const out = [...head(`Tokens per ${unit}`), ...p.lines, ...axis.lines.map((l) => this.paint("muted", l))];
416
+ out.push(...this.wrapParts([`From: ${series[0]?.day}`, `To: ${series.at(-1)?.day}`], inner));
308
417
  // Repeated wall-clock hours get an explicit offset-bearing label, even between axis edges.
309
418
  const labels = new Map<string, string>();
310
419
  for (const point of series) {
@@ -317,13 +426,9 @@ export class StatsView implements Component {
317
426
  const peak = Math.max(0, ...series.map((s) => s.total));
318
427
  const avg = series.reduce((n, s) => n + s.total, 0) / series.length;
319
428
  out.push(
320
- this.paint("muted", `total: peak ${formatTokens(peak)}/hour · avg ${formatTokens(avg)}/bucket · partial edges`),
321
- );
322
- out.push(
323
- ...this.legend(
324
- top.map((k) => k.key),
325
- colors,
326
- width - 1,
429
+ ...this.wrapParts(
430
+ [`total: peak ${formatTokens(peak)}/hour`, `avg ${formatTokens(avg)}/hour`, "partial edges"],
431
+ inner,
327
432
  ),
328
433
  );
329
434
  return { lines: out, colors };
@@ -331,41 +436,36 @@ export class StatsView implements Component {
331
436
  const from = rangeStart(this.range, this.today) ?? dayKey(ranged[0]?.ms ?? Date.now());
332
437
  const series = dailySeries(ranged, from, this.today, by === "provider" ? (c) => c.provider : modelName);
333
438
  const byDay = new Map(series.map((p) => [p.day, top.map((k) => p.byKey.get(k.key) ?? 0)]));
334
- // Same total width as the heatmap above it: its day labels + weeks == the y gutter + plot.
335
- const plotWidth = Math.max(MIN_PLOT_WIDTH, DAY_LABEL_WIDTH + weeksFor(contentWidth(width)) - GUTTER);
336
439
  const bucketed = bucketize(from, this.today, (d) => byDay.get(d) ?? [], top.length, plotWidth + 1);
337
- out.push(t.bold(`Tokens per ${bucketed.unit}`) + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`));
440
+ const out = head(`Tokens per ${bucketed.unit}`);
338
441
  if (series.length < 2) {
339
442
  out.push(this.paint("muted", "Not enough data for a chart yet"));
340
- } else {
341
- const p = plot(
342
- top.map((k, i) => ({
343
- values: bucketed.buckets.map((b) => b.values[i] ?? 0),
344
- color: colors.get(k.key) ?? 0,
345
- })),
346
- {
347
- width: plotWidth,
348
- height,
349
- paint: (color, text) =>
350
- color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
351
- },
352
- );
353
- out.push(...p.lines, this.paint("muted", xAxis(bucketed.buckets, plotWidth, p.gutter)));
354
- let peak = series[0];
355
- for (const point of series) if (point.total > (peak?.total ?? 0)) peak = point;
356
- const avg = series.reduce((n, point) => n + point.total, 0) / series.length;
357
- out.push(
358
- this.paint(
359
- "muted",
360
- `total: peak ${formatTokens(peak?.total ?? 0)} on ${shortDate(peak?.day ?? this.today)} · avg ${formatTokens(avg)}/day`,
361
- ),
362
- );
443
+ return { lines: out, colors };
363
444
  }
445
+ const p = plot(
446
+ top.map((k, i) => ({
447
+ values: bucketed.buckets.map((b) => b.values[i] ?? 0),
448
+ color: colors.get(k.key) ?? i,
449
+ })),
450
+ { width: plotWidth, height, paint },
451
+ );
452
+ const axis = timeAxis(
453
+ bucketed.buckets.map((b) => keyDate(b.start).getTime()),
454
+ { kind: "day", size: bucketed.size },
455
+ plotWidth,
456
+ p.gutter,
457
+ );
458
+ out.push(...p.lines, ...axis.lines.map((l) => this.paint("muted", l)));
459
+ let peak = series[0];
460
+ for (const point of series) if (point.total > (peak?.total ?? 0)) peak = point;
461
+ const avg = series.reduce((n, point) => n + point.total, 0) / series.length;
364
462
  out.push(
365
- ...this.legend(
366
- top.map((k) => k.key),
367
- colors,
368
- width - 1,
463
+ ...this.wrapParts(
464
+ [
465
+ `total: peak ${formatTokens(peak?.total ?? 0)} on ${shortDate(peak?.day ?? this.today)}`,
466
+ `avg ${formatTokens(avg)}/day`,
467
+ ],
468
+ inner,
369
469
  ),
370
470
  );
371
471
  return { lines: out, colors };
@@ -393,20 +493,22 @@ export class StatsView implements Component {
393
493
  }
394
494
 
395
495
  /**
396
- * Chart, range line and the model list, scrolled as one body like Overview so even a short pane
397
- * reaches every model. `owners[i]` holds the list positions of the models drawn on line `i`.
496
+ * Chart and the model list, scrolled as one body like Overview so even a short pane reaches every
497
+ * model. `owners[i]` holds the list positions of the models drawn on line `i`.
398
498
  */
399
499
  private modelsBody(width: number, room: number): { lines: string[]; owners: number[][]; count: number } {
400
500
  const { ranged } = this.scoped();
401
501
  const totals = modelTotals(ranged);
402
502
  if (totals.length === 0) {
403
- const lines = [this.selectors(), "", this.paint("muted", "No usage in this range.")];
503
+ const lines = [this.paint("muted", "No usage in this range.")];
404
504
  return { lines, owners: lines.map(() => []), count: 0 };
405
505
  }
406
- // The list scrolls under the chart, so the chart gets about half the pane.
407
- const chart = this.chart(ranged, width, chartHeight(Math.floor(room / 2) - 5));
408
- const head = [...chart.lines, "", this.selectors(), ""];
409
- const list = this.listRows(totals, width, chart.colors);
506
+ // The list scrolls under the chart: the chart gets what a short list leaves, at least half the pane.
507
+ const colors = this.chart(ranged, width, TICKS).colors;
508
+ const list = this.listRows(totals, contentWidth(width), colors);
509
+ const listLines = list.reduce((n, row) => n + row.lines.length, 0);
510
+ const chart = this.chart(ranged, width, chartHeight(Math.max(Math.floor(room / 2), room - listLines) - 7));
511
+ const head = [...chart.lines, ""];
410
512
  return {
411
513
  lines: [...head, ...list.flatMap((row) => row.lines)],
412
514
  owners: [...head.map(() => []), ...list.flatMap((row) => row.lines.map(() => row.models))],
@@ -415,19 +517,75 @@ export class StatsView implements Component {
415
517
  }
416
518
 
417
519
  /**
418
- * Provider headers, then that provider's models: two per row when the pane is `TWO_COLUMN_WIDTH`
419
- * or wider, else one. Rows are indented two columns inside the overlay's one-column margin.
520
+ * Models grouped under their provider: a table when its columns fit, one aligned line per model with a
521
+ * provider subtotal line above its models, else three-line cards one per row. Indented two columns.
420
522
  */
421
- private listRows(totals: readonly ModelTotals[], width: number, colors: ReadonlyMap<string, number>): ListRow[] {
523
+ private listRows(totals: readonly ModelTotals[], inner: number, colors: ReadonlyMap<string, number>): ListRow[] {
422
524
  const t = this.o.theme;
423
525
  const grand = totals.reduce((n, m) => n + tokensOf(m), 0);
424
- const twoUp = width >= TWO_COLUMN_WIDTH;
425
- const inner = Math.max(1, width - 3);
426
- const colWidth = twoUp ? Math.floor(inner / 2) : inner;
427
526
  const bullet = (key: string) => {
428
527
  const index = colors.get(key);
429
528
  return index === undefined ? this.paint("muted", "•") : this.paintSeries(index, colors.size, "•");
430
529
  };
530
+ const sumOf = (models: readonly ModelTotals[]): Totals => ({
531
+ input: models.reduce((n, m) => n + m.input, 0),
532
+ output: models.reduce((n, m) => n + m.output, 0),
533
+ cacheRead: models.reduce((n, m) => n + m.cacheRead, 0),
534
+ cacheWrite: models.reduce((n, m) => n + m.cacheWrite, 0),
535
+ cost: models.reduce((n, m) => n + m.cost, 0),
536
+ });
537
+ const providers = [...new Set(totals.map((m) => m.provider))].map((provider) => {
538
+ const models = totals.filter((m) => m.provider === provider);
539
+ const sub = sumOf(models);
540
+ return { provider, models, sub, sum: models.reduce((n, m) => n + tokensOf(m), 0) };
541
+ });
542
+ const rows: ListRow[] = [];
543
+ let position = 0;
544
+ const numbers = (m: Totals, sum: number) => [
545
+ formatTokens(sum),
546
+ percent(sum, grand),
547
+ formatTokens(m.input),
548
+ formatTokens(m.output),
549
+ formatTokens(m.cacheRead),
550
+ formatTokens(m.cacheWrite),
551
+ money(m.cost),
552
+ ];
553
+ const heads = ["Tokens", "Share", "In", "Out", "Cache read", "Cache write", "Cost (notional)"];
554
+ const nameWidth = Math.max(
555
+ ...totals.map((m) => visibleWidth(sanitize(m.model)) + 4),
556
+ ...providers.map((p) => visibleWidth(sanitize(p.provider)) + 2),
557
+ );
558
+ const widths = heads.map((h, c) =>
559
+ Math.max(
560
+ h.length,
561
+ ...totals.map((m) => (numbers(m, tokensOf(m))[c] ?? "").length),
562
+ ...providers.map((p) => (numbers(p.sub, p.sum)[c] ?? "").length),
563
+ ),
564
+ );
565
+ const tableWidth = 2 + nameWidth + widths.reduce((n, x) => n + x + 2, 0);
566
+ if (tableWidth <= inner) {
567
+ const line = (name: string, cells: readonly string[], paint: (s: string) => string) =>
568
+ ` ${padTo(name, nameWidth)}${cells.map((cell, c) => paint(cell.padStart((widths[c] ?? 0) + 2))).join("")}`;
569
+ rows.push({ lines: [line("", heads, (s) => this.paint("muted", s))], models: [] });
570
+ for (const { provider, models, sub, sum } of providers) {
571
+ rows.push({
572
+ lines: [line(`${bullet(provider)} ${t.bold(sanitize(provider))}`, numbers(sub, sum), (s) => t.bold(s))],
573
+ models: [],
574
+ });
575
+ for (const m of models)
576
+ rows.push({
577
+ lines: [
578
+ line(
579
+ `${colors.has(m.name) ? ` ${bullet(m.name)} ` : " "}${sanitize(m.model)}`,
580
+ numbers(m, tokensOf(m)),
581
+ (s) => this.paint("muted", s),
582
+ ),
583
+ ],
584
+ models: [position++],
585
+ });
586
+ }
587
+ return rows;
588
+ }
431
589
  const entry = (m: ModelTotals): string[] => [
432
590
  `${bullet(m.name)} ${t.bold(sanitize(m.model))} ${this.paint("muted", `(${percent(tokensOf(m), grand)})`)}`,
433
591
  this.paint("muted", ` In: ${formatTokens(m.input)} · Out: ${formatTokens(m.output)}`),
@@ -436,40 +594,34 @@ export class StatsView implements Component {
436
594
  ` Cache: ${formatTokens(m.cacheRead)} read · ${formatTokens(m.cacheWrite)} write · ${money(m.cost)} notional`,
437
595
  ),
438
596
  ];
439
- const rows: ListRow[] = [];
440
- let position = 0;
441
- for (const provider of [...new Set(totals.map((m) => m.provider))]) {
442
- const models = totals.filter((m) => m.provider === provider).map((m) => ({ m, i: position++ }));
443
- const sum = models.reduce((n, { m }) => n + tokensOf(m), 0);
597
+ for (const { provider, models, sum } of providers) {
444
598
  rows.push({
445
599
  lines: [
446
600
  `${bullet(provider)} ${t.bold(sanitize(provider))} ${formatTokens(sum)} ${this.paint("muted", `(${percent(sum, grand)})`)}`,
447
601
  ],
448
602
  models: [],
449
603
  });
450
- const per = twoUp ? 2 : 1;
451
- for (let k = 0; k < models.length; k += per) {
452
- const group = models.slice(k, k + per);
453
- const blocks = group.map(({ m }) => entry(m));
454
- const lines = [0, 1, 2].map((line) =>
455
- blocks
456
- .map((b, col) => {
457
- const cell = truncateToWidth(
458
- b[line] ?? "",
459
- Math.max(1, colWidth - (col < blocks.length - 1 ? 2 : 0)),
460
- "…",
461
- );
462
- return col < blocks.length - 1 ? cell + " ".repeat(Math.max(0, colWidth - visibleWidth(cell))) : cell;
463
- })
464
- .join(""),
465
- );
466
- rows.push({ lines: lines.map((l) => ` ${l}`), models: group.map(({ i }) => i) });
467
- }
604
+ for (const m of models)
605
+ rows.push({
606
+ lines: entry(m).map((l) => ` ${truncateToWidth(l, Math.max(1, inner - 2), "…")}`),
607
+ models: [position++],
608
+ });
468
609
  }
469
610
  return rows;
470
611
  }
471
612
  }
472
613
 
614
+ /** `text` padded with spaces to `width` columns (ANSI-aware); never cuts. */
615
+ function padTo(text: string, width: number): string {
616
+ return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
617
+ }
618
+
619
+ /** `left` and `right` on one `width`-column line, right flush; `undefined` if they would touch. */
620
+ function spread(left: string, right: string, width: number): string | undefined {
621
+ const gap = width - visibleWidth(left) - visibleWidth(right);
622
+ return gap >= 3 ? `${left}${" ".repeat(gap)}${right}` : undefined;
623
+ }
624
+
473
625
  /** Plain-text per-model table for print/json/rpc modes, all time. */
474
626
  export function summary(calls: readonly Call[]): string {
475
627
  const totals = modelTotals(calls);
package/src/stats/days.ts CHANGED
@@ -35,6 +35,13 @@ export function dayRange(from: string, to: string): string[] {
35
35
  return out;
36
36
  }
37
37
 
38
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
39
+
40
+ /** `Mon` for `Date#getDay()` 1. */
41
+ export function weekdayName(day: number): string {
42
+ return WEEKDAYS[day] ?? "";
43
+ }
44
+
38
45
  export function monthName(month: number): string {
39
46
  return MONTHS[month] ?? "";
40
47
  }
@@ -8,6 +8,10 @@ import { addDays, keyDate, monthName } from "./days.ts";
8
8
 
9
9
  /** A year: the heatmap shows at least this many weeks whenever the pane fits them. */
10
10
  export const YEAR_WEEKS = 52;
11
+ /** Two years: older weeks are dead space, so wide panes stop here. */
12
+ export const MAX_WEEKS = 104;
13
+ /** Columns the newest month's name can run past the last week: callers leave this much room. */
14
+ export const MONTH_OVERFLOW = 2;
11
15
  export const DAY_LABEL_WIDTH = 4;
12
16
  const LEVEL_CHARS = ["·", "░", "▒", "▓", "█"] as const;
13
17
 
@@ -50,9 +54,12 @@ export function monthRow(start: string, weeks: number): string {
50
54
  return row.join("").trimEnd();
51
55
  }
52
56
 
53
- /** How many week columns fit `width`: one per column after the day labels, so wider shows more history. */
57
+ /**
58
+ * How many week columns fit `width`: one per column after the day labels and the room the newest
59
+ * month's name may run into, up to `MAX_WEEKS`.
60
+ */
54
61
  export function weeksFor(width: number): number {
55
- return Math.max(1, width - DAY_LABEL_WIDTH);
62
+ return Math.min(MAX_WEEKS, Math.max(1, width - DAY_LABEL_WIDTH - MONTH_OVERFLOW));
56
63
  }
57
64
 
58
65
  /**
@@ -0,0 +1,241 @@
1
+ /**
2
+ * X-axis ticks for the `/stats` time-series charts, one rule for every timescale.
3
+ *
4
+ * A chart has `n` buckets stretched over `width` plot columns, bucket `k` on column
5
+ * `round(k * width / (n - 1))` (see `stretch`). A tick is a bucket. Candidate tick sets run from fine
6
+ * to coarse (every hour, every 2 hours, ... / every day, every Monday, every 14 days, ... / every
7
+ * month, every quarter, ...), each with a wide and a narrow label format. The first set whose labels
8
+ * all fit, with air between them and at most `MAX_TICKS` of them, wins. So ticks are evenly spaced
9
+ * (clock- or calendar-aligned), never overlap, and are never cut: a label that cannot fit is not drawn.
10
+ */
11
+
12
+ import { dayKey, monthName, shortDate, weekdayName } from "./days.ts";
13
+
14
+ /** What one bucket spans. `size` is days per bucket for the daily scale. */
15
+ export type Timescale = { kind: "hour" } | { kind: "day"; size: number };
16
+
17
+ export interface Tick {
18
+ /** Bucket index. */
19
+ bucket: number;
20
+ /** Plot column the bucket sits on. */
21
+ column: number;
22
+ label: string;
23
+ /** Column the label's first character is drawn in. */
24
+ start: number;
25
+ }
26
+
27
+ export interface TimeAxis {
28
+ ticks: Tick[];
29
+ /** Baseline with a mark per tick, labels, and for hourly charts a row of dates. */
30
+ lines: string[];
31
+ }
32
+
33
+ /** More labels than this stop reading as an axis. */
34
+ export const MAX_TICKS = 13;
35
+ /** Hours between ticks, finest first; all divide a day so ticks land on the clock. */
36
+ const HOUR_STRIDES = [1, 2, 3, 4, 6, 12, 24] as const;
37
+ /** Days between ticks for the daily scale, finest first. */
38
+ const DAY_STRIDES = [1, 2, 3, 7, 14] as const;
39
+ /** Months between ticks, finest first. */
40
+ const MONTH_STRIDES = [1, 2, 3, 6, 12] as const;
41
+ /** Weekday names label every bucket up to this many days. */
42
+ const WEEKDAY_MAX = 8;
43
+ /** Blank columns required between two labels, for the wide and the narrow variant. */
44
+ const GAP = [3, 2] as const;
45
+
46
+ interface Mark {
47
+ bucket: number;
48
+ /** Wide to narrow. */
49
+ labels: string[];
50
+ }
51
+
52
+ const pad2 = (n: number): string => (n < 10 ? `0${n}` : String(n));
53
+
54
+ /** `09:00`, or `05:30` where the zone's hours start at :30. */
55
+ export function hourWide(ms: number): string {
56
+ const d = new Date(ms);
57
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
58
+ }
59
+
60
+ /** `9a`, `12p`: the short 12-hour form. Half-hour zones keep `05:30`, already as short as it gets. */
61
+ export function hourNarrow(ms: number): string {
62
+ const d = new Date(ms);
63
+ if (d.getMinutes()) return hourWide(ms);
64
+ return `${d.getHours() % 12 || 12}${d.getHours() < 12 ? "a" : "p"}`;
65
+ }
66
+
67
+ /** Bucket column on a `width`-column plot, the last bucket on the last column. */
68
+ export function bucketColumn(bucket: number, count: number, width: number): number {
69
+ if (count <= 1 || width <= 1) return 0;
70
+ return Math.min(width - 1, Math.round((bucket * width) / (count - 1)));
71
+ }
72
+
73
+ /**
74
+ * Every `stride`-th bucket from the first one on the clock. Stepping by bucket, not by local hour,
75
+ * keeps ticks evenly spaced across a DST change; labels after it shift by the hour gained or lost.
76
+ */
77
+ function hourLevels(starts: readonly number[]): Mark[][] {
78
+ return HOUR_STRIDES.map((stride) => {
79
+ const marks: Mark[] = [];
80
+ const anchor = starts.findIndex((ms) => new Date(ms).getHours() % stride === 0);
81
+ for (let bucket = anchor; anchor >= 0 && bucket < starts.length; bucket += stride) {
82
+ const ms = starts[bucket] ?? 0;
83
+ marks.push({ bucket, labels: [hourWide(ms), hourNarrow(ms)] });
84
+ }
85
+ return marks;
86
+ });
87
+ }
88
+
89
+ /** Buckets `stride` apart counted back from the newest, so today is always labelled. */
90
+ function fromNewest(count: number, stride: number): number[] {
91
+ const out: number[] = [];
92
+ for (let k = count - 1; k >= 0; k -= stride) out.unshift(k);
93
+ return out;
94
+ }
95
+
96
+ function dayLevels(starts: readonly number[], size: number): Mark[][] {
97
+ const n = starts.length;
98
+ const levels: Mark[][] = [];
99
+ if (size === 1 && n <= WEEKDAY_MAX) {
100
+ levels.push(
101
+ starts.map((ms, bucket) => ({
102
+ bucket,
103
+ labels: [`${weekdayName(new Date(ms).getDay())} ${new Date(ms).getDate()}`],
104
+ })),
105
+ );
106
+ levels.push(starts.map((ms, bucket) => ({ bucket, labels: [weekdayName(new Date(ms).getDay())] })));
107
+ }
108
+ for (const days of DAY_STRIDES) {
109
+ if (days % size !== 0) continue;
110
+ const stride = days / size;
111
+ let buckets = fromNewest(n, stride);
112
+ if (size === 1 && days % 7 === 0) {
113
+ // Whole weeks: every `days`-th Monday, newest first.
114
+ const newest = starts.map((ms) => new Date(ms).getDay()).lastIndexOf(1);
115
+ buckets = newest < 0 ? [] : fromNewest(newest + 1, stride);
116
+ }
117
+ levels.push(buckets.map((bucket) => ({ bucket, labels: [shortDate(dayKey(starts[bucket] ?? 0))] })));
118
+ }
119
+ // A bucket carries the month whose first day it contains; the first label of a year carries it.
120
+ // A bucket ends where the next starts (the first is often partial, DST days are 23 or 25 hours);
121
+ // the last spans `size` calendar days.
122
+ const firsts: { bucket: number; month: number; year: number }[] = [];
123
+ starts.forEach((ms, bucket) => {
124
+ const d = new Date(ms);
125
+ const end = starts[bucket + 1] ?? new Date(d.getFullYear(), d.getMonth(), d.getDate() + size).getTime();
126
+ const first = d.getDate() === 1 ? d : new Date(d.getFullYear(), d.getMonth() + 1, 1);
127
+ if (first.getTime() < end) firsts.push({ bucket, month: first.getMonth(), year: first.getFullYear() });
128
+ });
129
+ for (const stride of MONTH_STRIDES) {
130
+ const picked = firsts.filter((f) => f.month % stride === 0);
131
+ // Years only when the ticks span more than one, on the first tick and every January; then no
132
+ // variant drops them, so `Jan`, `Jan` never repeats unlabelled.
133
+ const years = new Set(picked.map((f) => f.year)).size > 1;
134
+ levels.push(
135
+ picked.map((f, i) => {
136
+ const plain = monthName(f.month);
137
+ const tagged = years && (i === 0 || f.month === 0) ? `${plain} '${pad2(f.year % 100)}` : plain;
138
+ return { bucket: f.bucket, labels: years ? [tagged] : [plain] };
139
+ }),
140
+ );
141
+ }
142
+ return levels;
143
+ }
144
+
145
+ /** Label start columns when `marks` use label variant `variant`, or `undefined` if any collide or overflow. */
146
+ function place(marks: readonly Mark[], variant: number, count: number, width: number): Tick[] | undefined {
147
+ const gap = GAP[Math.min(variant, GAP.length - 1)] ?? 2;
148
+ const ticks: Tick[] = [];
149
+ let end = -gap;
150
+ for (const m of marks) {
151
+ const label = m.labels[Math.min(variant, m.labels.length - 1)] ?? "";
152
+ const column = bucketColumn(m.bucket, count, width);
153
+ if (label.length > width) return undefined;
154
+ const start = Math.max(0, Math.min(width - label.length, column - Math.floor(label.length / 2)));
155
+ if (start < end + gap) return undefined;
156
+ ticks.push({ bucket: m.bucket, column, label, start });
157
+ end = start + label.length;
158
+ }
159
+ return ticks;
160
+ }
161
+
162
+ /**
163
+ * Pick the ticks for buckets starting at `starts` (local instants) on a `width`-column plot. Falls
164
+ * back to the first and last bucket, then the last alone, when no regular set fits.
165
+ */
166
+ export function chooseTicks(starts: readonly number[], scale: Timescale, width: number): Tick[] {
167
+ const n = starts.length;
168
+ if (n === 0 || width <= 0) return [];
169
+ const levels = scale.kind === "hour" ? hourLevels(starts) : dayLevels(starts, scale.size);
170
+ const endLabel = (b: number): Mark => ({
171
+ bucket: b,
172
+ labels: [scale.kind === "hour" ? hourWide(starts[b] ?? 0) : shortDate(dayKey(starts[b] ?? 0))],
173
+ });
174
+ const ends = [[endLabel(0), endLabel(n - 1)], [endLabel(n - 1)]];
175
+ for (const [i, marks] of [...levels, ...ends].entries()) {
176
+ const regular = i < levels.length;
177
+ if (marks.length === 0 || (regular && (marks.length < 2 || marks.length > MAX_TICKS))) continue;
178
+ if (n === 1 && marks.length > 1) continue;
179
+ for (let variant = 0; variant < 2; variant++) {
180
+ const ticks = place(marks, variant, n, width);
181
+ if (ticks) return ticks;
182
+ }
183
+ }
184
+ return [];
185
+ }
186
+
187
+ /** A row of `text` pieces at their start columns; callers pass non-overlapping pieces. */
188
+ function row(width: number, pieces: readonly { start: number; text: string }[]): string {
189
+ const cells = new Array<string>(width).fill(" ");
190
+ for (const p of pieces)
191
+ for (let i = 0; i < p.text.length && p.start + i < width; i++) cells[p.start + i] = p.text[i] ?? " ";
192
+ return cells.join("").trimEnd();
193
+ }
194
+
195
+ /**
196
+ * Dates under an hourly axis: one at every bucket that starts a new local day and, room allowing, one
197
+ * under the first bucket, so a 24h chart reads `Oct 12 ... Oct 13` across its midnight.
198
+ */
199
+ function dateRow(starts: readonly number[], width: number): string {
200
+ const n = starts.length;
201
+ const pieces: { start: number; text: string }[] = [];
202
+ const free = (start: number, len: number) =>
203
+ pieces.every((p) => start >= p.start + p.text.length + 2 || start + len + 2 <= p.start);
204
+ const add = (bucket: number) => {
205
+ const text = shortDate(dayKey(starts[bucket] ?? 0));
206
+ const column = bucketColumn(bucket, n, width);
207
+ const start = Math.max(0, Math.min(width - text.length, column));
208
+ if (text.length <= width && free(start, text.length)) pieces.push({ start, text });
209
+ };
210
+ // Every bucket on a new local day: midnight, or 00:30 in half-hour zones, wherever DST puts it.
211
+ starts.forEach((ms, bucket) => {
212
+ if (bucket > 0 && dayKey(ms) !== dayKey(starts[bucket - 1] ?? ms)) add(bucket);
213
+ });
214
+ add(0);
215
+ return row(width, pieces);
216
+ }
217
+
218
+ /**
219
+ * The x-axis under a plot: a baseline (`└──┬──┬──`), the tick labels, and for hourly charts the dates.
220
+ * `gutter` is the plot's left margin (`GUTTER`); lines are plain text, painted by the caller.
221
+ */
222
+ export function timeAxis(starts: readonly number[], scale: Timescale, width: number, gutter: number): TimeAxis {
223
+ const ticks = chooseTicks(starts, scale, width);
224
+ const base = new Array<string>(width).fill("─");
225
+ for (const t of ticks) base[t.column] = "┬";
226
+ const margin = " ".repeat(gutter);
227
+ const lines = [`${" ".repeat(Math.max(0, gutter - 1))}└${base.join("")}`];
228
+ if (ticks.length > 0)
229
+ lines.push(
230
+ margin +
231
+ row(
232
+ width,
233
+ ticks.map((t) => ({ start: t.start, text: t.label })),
234
+ ),
235
+ );
236
+ if (scale.kind === "hour") {
237
+ const dates = dateRow(starts, width);
238
+ if (dates) lines.push(margin + dates);
239
+ }
240
+ return { ticks, lines };
241
+ }