@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 +27 -11
- package/package.json +1 -1
- package/src/stats/aggregate.ts +3 -0
- package/src/stats/chart.ts +3 -34
- package/src/stats/component.ts +290 -138
- package/src/stats/days.ts +7 -0
- package/src/stats/heatmap.ts +9 -2
- package/src/stats/ticks.ts +241 -0
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).
|
|
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`.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
package/src/stats/aggregate.ts
CHANGED
|
@@ -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,
|
package/src/stats/chart.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
-
}
|
package/src/stats/component.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
68
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
326
|
+
const inner = contentWidth(width);
|
|
234
327
|
const out: string[] = [];
|
|
235
328
|
const daily = dailyTotals(all);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
283
|
-
*
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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.
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
397
|
-
*
|
|
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.
|
|
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
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
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
|
-
*
|
|
419
|
-
*
|
|
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[],
|
|
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
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
}
|
package/src/stats/heatmap.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|